Lesson 13 • Expert
Security Best Practices 🔒
By the end of this lesson you'll be able to spot and fix the most common PHP security holes — SQL injection, XSS, and CSRF — and harden passwords, sessions, file uploads, and HTTP headers the way real production apps do.
What You'll Learn in This Lesson
- Stop SQL injection with prepared statements and bound values
- Stop XSS by escaping output with htmlspecialchars() and a CSP
- Protect forms from CSRF with per-session tokens and hash_equals()
- Hash passwords safely with password_hash() and password_verify()
- Lock down sessions and cookies (httponly, secure, samesite)
- Handle file uploads and SSRF safely, and send security headers
$_GET and $_POST.php file.php. The Output panel under each example shows exactly what to expect.1️⃣ SQL Injection — Prepared Statements
SQL injection is the classic, still-number-one web attack. It happens when you glue user input directly into a SQL string: the input then stops being data and becomes part of the command. The fix is a prepared statement — you send the query with a ? placeholder first, then send the value separately, so the database can never confuse the two. This is exactly why the Database lesson taught you prepare() and execute().
<?php
// === SQL INJECTION: never paste user input straight into a query ===
// Imagine this email arrived from a login form ($_POST['email']).
$email = "' OR '1'='1"; // attacker input, not a real address
// ❌ VULNERABLE: the input becomes PART OF the SQL command.
$badSql = "SELECT * FROM users WHERE email = '{$email}'";
echo "Vulnerable query sent to the database:\n {$badSql}\n";
echo " -> 'OR 1=1' is always true, so the WHERE matches EVERY row.\n";
echo " -> the attacker is logged in as the first user, no password needed.\n\n";
// ✅ FIXED: a prepared statement sends the SQL and the value on SEPARATE
// channels. The ? is a placeholder; the value can never become code.
// \$pdo = new PDO('mysql:host=localhost;dbname=app', \$user, \$pass);
// \$stmt = \$pdo->prepare('SELECT * FROM users WHERE email = ?');
// \$stmt->execute([\$email]); // value bound, structure fixed
// \$row = \$stmt->fetch();
echo "Fixed: prepare('... WHERE email = ?') then execute([\$email]).\n";
echo "The database treats \$email as DATA, never as part of the query.\n";Vulnerable query sent to the database:
SELECT * FROM users WHERE email = '' OR '1'='1'
-> 'OR 1=1' is always true, so the WHERE matches EVERY row.
-> the attacker is logged in as the first user, no password needed.
Fixed: prepare('... WHERE email = ?') then execute([$email]).
The database treats $email as DATA, never as part of the query.Read the vulnerable query out loud: the attacker's ' OR '1'='1 closes your quote and adds a condition that's always true. With a prepared statement the value stays in its own lane — there's no way to "escape" into the SQL.
2️⃣ XSS — Escape Output & Use a CSP
XSS (cross-site scripting) is the mirror image of SQL injection, but the victim is the browser instead of the database. If you echo untrusted text into a page without escaping it, an attacker can slip in a <script> tag that then runs in every visitor's browser — stealing cookies or hijacking the session. The fix: run every piece of untrusted output through htmlspecialchars(), which converts < > " ' & into harmless entities. Add a Content-Security-Policy header as a second wall.
<?php
// === XSS (Cross-Site Scripting): escape untrusted data before output ===
// This comment text supposedly came from a visitor ($_GET['msg']).
$comment = '<script>steal(document.cookie)</script>';
// ❌ VULNERABLE: echoing raw input puts the attacker's <script> into the
// page. Every other visitor's browser then RUNS it.
$vulnerable = "<p>{$comment}</p>";
// ✅ FIXED: htmlspecialchars() turns < > " ' & into harmless HTML entities,
// so the browser shows the text instead of executing it.
$safe = "<p>" . htmlspecialchars($comment, ENT_QUOTES, 'UTF-8') . "</p>";
echo "Raw (vulnerable): {$vulnerable}\n";
echo "Escaped (safe): {$safe}\n";
echo "\n";
echo "Defence in depth: also send a Content-Security-Policy header\n";
echo " header(\"Content-Security-Policy: default-src 'self'\");\n";
echo "so even an injected <script> is blocked by the browser.\n";Raw (vulnerable): <p><script>steal(document.cookie)</script></p>
Escaped (safe): <p><script>steal(document.cookie)</script></p>
Defence in depth: also send a Content-Security-Policy header
header("Content-Security-Policy: default-src 'self'");
so even an injected <script> is blocked by the browser.In the safe output the < became <, so the browser displays the text instead of running it. Always escape at the moment of output, and always say which charset ('UTF-8') you mean.
3️⃣ CSRF — Per-Session Tokens
CSRF (cross-site request forgery) tricks a logged-in user's browser into firing a request at your site — say, "change my email" — without them realising. Because the browser automatically attaches their session cookie, your server thinks it's genuine. The defence is a secret token that only your own pages know: put a random value in a hidden form field, store the same value in the session, and reject any POST whose token doesn't match. Compare with hash_equals(), not ===, so the comparison takes constant time and leaks no timing clues.
<?php
// === CSRF (Cross-Site Request Forgery): prove the request came from YOU ===
// A CSRF attack tricks a logged-in user's browser into submitting a form to
// your site (e.g. "transfer money") without them meaning to. A secret token
// that only your real pages know stops it.
// session_start(); // (skipped here so this runs as a plain script)
// On GET: generate a random token, store it, put it in a hidden form field.
$token = bin2hex(random_bytes(32)); // 64 hex chars, unguessable
echo "Hidden field: <input type=\"hidden\" name=\"csrf\" value=\"" . substr($token, 0, 12) . "...\">\n\n";
// On POST: compare the submitted token with the stored one.
$stored = $token; // would be \$_SESSION['csrf']
$submitted = $token; // would be \$_POST['csrf']
// ❌ WRONG: a plain === leaks timing information about the secret.
// ✅ RIGHT: hash_equals() compares in constant time.
$ok = hash_equals($stored, $submitted);
echo "Submitted token matches the session token? " . var_export($ok, true) . "\n";
$forged = hash_equals($stored, 'attacker-guessed-value');
echo "A forged request's token matches? " . var_export($forged, true) . "\n";Hidden field: <input type="hidden" name="csrf" value="a1b2c3d4e5f6...">
Submitted token matches the session token? true
A forged request's token matches? false4️⃣ Passwords, Sessions & Cookies
Never store a real password — store a hash. Use password_hash() (bcrypt/Argon2): it's deliberately slow, salts every hash for you, and pairs with password_verify() at login. Then lock down the session cookie: httponly hides it from JavaScript (blunting XSS theft), secure sends it only over HTTPS, and samesite=Strict keeps it off cross-site requests (blunting CSRF). Call session_regenerate_id() right after login to defeat session fixation.
<?php
// === PASSWORD HASHING: never store the real password ===
$password = 'MyS3cur3P@ss';
// ❌ NEVER: md5()/sha1() are fast checksums — a GPU cracks billions/sec.
// \$bad = md5(\$password);
// ✅ Registration: password_hash() uses bcrypt/argon2 — slow and salted FOR you.
$hash = password_hash($password, PASSWORD_DEFAULT);
echo "Stored hash (~60 chars, different every run):\n " . substr($hash, 0, 30) . "...\n\n";
// ✅ Login: verify the typed password against the stored hash.
echo "Correct password verifies? " . var_export(password_verify('MyS3cur3P@ss', $hash), true) . "\n";
echo "Wrong password verifies? " . var_export(password_verify('wrongpass', $hash), true) . "\n\n";
// === SECURE SESSIONS & COOKIES ===
// Set these BEFORE session_start() so the session cookie is locked down:
// session_set_cookie_params([
// 'httponly' => true, // JavaScript can't read it -> blunts XSS cookie theft
// 'secure' => true, // only sent over HTTPS
// 'samesite' => 'Strict' // not sent on cross-site requests -> blunts CSRF
// ]);
// session_start();
// session_regenerate_id(true); // new ID after login -> stops session fixation
echo "Cookie flags to set: httponly, secure, samesite=Strict.\n";Stored hash (~60 chars, different every run):
$2y$10$Q8s1Z3kf9aB2cD4eF6gH7u...
Correct password verifies? true
Wrong password verifies? false
Cookie flags to set: httponly, secure, samesite=Strict.password_hash() adds a random salt, so the stored hash differs on every run — paste this into onecompiler.com/php to see your own hash. The verify booleans are deterministic.5️⃣ Validate Input, Uploads, SSRF & Headers
The thread running through every section is one rule: never trust user input. Validate its shape with filter_var(). For file uploads, trust the bytes (the real MIME type) not the filename, give the file a random name, and store it outside the webroot so it can't be executed. For SSRF, never fetch a user-supplied URL without an allow-list of permitted hosts. And set security headers — X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, Content-Security-Policy — on every response.
<?php
// === NEVER TRUST INPUT: validate, then act ===
// filter_var() checks the SHAPE of a value and returns false if it's wrong.
$emails = ['good@mail.com', 'not-an-email'];
foreach ($emails as $e) {
$ok = filter_var($e, FILTER_VALIDATE_EMAIL) !== false;
echo str_pad($e, 16) . " -> valid email? " . var_export($ok, true) . "\n";
}
echo "age '42' is an int? " . var_export(filter_var('42', FILTER_VALIDATE_INT) !== false, true) . "\n";
echo "age '4.2' is an int? " . var_export(filter_var('4.2', FILTER_VALIDATE_INT) !== false, true) . "\n\n";
// === FILE UPLOADS: trust the bytes, not the name ===
// ❌ "photo.jpg.php" looks like an image but RUNS as PHP if you keep the name.
// ✅ Check the real MIME type, give it a random name, store OUTSIDE the webroot:
// \$mime = (new finfo(FILEINFO_MIME_TYPE))->file(\$tmp); // read the bytes
// if (!in_array(\$mime, ['image/jpeg','image/png'])) reject();
// \$safeName = bin2hex(random_bytes(16)) . '.jpg';
// move_uploaded_file(\$tmp, '/var/uploads/' . \$safeName);
echo "Upload rule: check MIME, rename randomly, store outside the webroot.\n";
// === SSRF: never fetch a URL the user supplied without checking it ===
// An attacker passes http://169.254.169.254/ to reach internal cloud metadata.
// ✅ Allow-list the hosts you intend to call; block private/loopback IPs.
echo "SSRF rule: allow-list outbound URLs; reject internal/private addresses.\n";
// === SECURITY HEADERS: set them before ANY output ===
// header('X-Content-Type-Options: nosniff');
// header('X-Frame-Options: DENY');
// header('Strict-Transport-Security: max-age=31536000');
// header(\"Content-Security-Policy: default-src 'self'\");
echo "Send security headers on every response.\n";good@mail.com -> valid email? true
not-an-email -> valid email? false
age '42' is an int? true
age '4.2' is an int? false
Upload rule: check MIME, rename randomly, store outside the webroot.
SSRF rule: allow-list outbound URLs; reject internal/private addresses.
Send security headers on every response.Now you try. The script below is almost complete — fill in each ___ using the 👉 hint, then run it and check it against the Output panel.
<?php
// 🎯 YOUR TURN — make the user's comment safe to print.
// The attacker tucked a <script> tag inside their "comment".
$comment = $_GET['comment'] ?? '<script>alert(1)</script>';
// 1) Escape $comment so the browser shows it as text, not code.
// Use htmlspecialchars() with ENT_QUOTES and the 'UTF-8' charset.
$safe = ___; // 👉 htmlspecialchars($comment, ENT_QUOTES, 'UTF-8')
echo "<p>{$safe}</p>\n";
// ✅ Expected output:
// <p><script>alert(1)</script></p><p><script>alert(1)</script></p>___ with htmlspecialchars($comment, ENT_QUOTES, 'UTF-8'), then run it. The <script> should appear as escaped text.One more. Turn a dangerous concatenated query into a parameterised one by placing a single ? where the value belongs.
<?php
// 🎯 YOUR TURN — rewrite a dangerous query as a prepared statement.
// $username comes from a login form, so it can NOT be trusted.
$username = "admin' --"; // attacker input that comments out the rest
// ❌ The vulnerable version (do NOT use):
// \$sql = "SELECT * FROM users WHERE name = '{\$username}'";
// 1) Replace the value with a ? placeholder in the SQL string:
$sql = "SELECT * FROM users WHERE name = ___"; // 👉 put a single ? where the value goes
// 2) Pass the real value to execute() as an array element:
// \$stmt = \$pdo->prepare(\$sql);
// \$stmt->execute([___]); // 👉 \$username
echo "Prepared SQL: {$sql}\n";
// ✅ Expected output:
// Prepared SQL: SELECT * FROM users WHERE name = ?Prepared SQL: SELECT * FROM users WHERE name = ?? placeholder where the value goes, and pass $username to execute([...]). The printed SQL should end with name = ?.Common Errors (and the fix)
- You escape input before storing it in the database — then it shows up double-escaped or mangled on the page. Rule: store the raw value, and escape with
htmlspecialchars()only at the moment you output it into HTML. - "Warning: Cannot modify header information — headers already sent" — you called
header()orsession_start()after something was already echoed (even a blank line or BOM before<?php). Set all headers and start the session before any output. - You use
===to compare a CSRF token or hash — it works but leaks timing and, worse, returns the wrong answer for some inputs. Usehash_equals()for secrets andpassword_verify()for passwords. - "SQLSTATE... syntax error" once you switch to placeholders — you quoted the placeholder as
'?'. A bound parameter is never quoted: writeWHERE email = ?, notWHERE email = '?'. - Uploads still get executed as PHP — you kept the original filename. Check the MIME type with
finfo, rename the file to something random, and store it outside the public webroot.
Pro Tips
- 💡 Escape per context. HTML output needs
htmlspecialchars(); a URL needsurlencode(); SQL needs a bound parameter. There's no one "make safe" function. - 💡 Validate and escape. Validation rejects the wrong shape; escaping makes a value safe for where it's going. You need both — they're not the same job.
- 💡 Use the OWASP Top 10 as a checklist. Before any deploy, walk the list — injection, broken access control, security misconfiguration — and confirm each is handled.
📋 Quick Reference — Threat → Defence
| Threat | Defence | In PHP |
|---|---|---|
| SQL injection | Prepared statements | $pdo->prepare('... = ?') |
| XSS | Escape output + CSP | htmlspecialchars($x, ENT_QUOTES) |
| CSRF | Per-session token | hash_equals($sess, $sent) |
| Weak passwords | Slow, salted hash | password_hash() / password_verify() |
| Session hijack/fixation | Hardened cookie + new ID | httponly,secure,samesite; regenerate_id() |
| Bad / malicious input | Validate shape | filter_var($x, FILTER_VALIDATE_*) |
| Malicious file upload | MIME + rename + move | finfo; random name; outside webroot |
| SSRF | Allow-list URLs | block private/loopback hosts |
| Clickjacking / sniffing | Security headers | header('X-Frame-Options: DENY') |
Frequently Asked Questions
Q: What is the single most important PHP security rule?
Never trust user input. Anything that comes from outside your server — form fields, URL query strings, cookies, headers, uploaded files, even API responses — could be crafted by an attacker. Validate it for shape (filter_var), escape it for the place it's going (htmlspecialchars for HTML), and bind it as data (prepared statements for SQL). Every vulnerability in the OWASP Top 10 is, at heart, input that was trusted when it shouldn't have been.
Q: Do prepared statements stop every kind of injection?
They stop SQL injection for the values you bind, which is the common case. They do not protect the parts of a query that can't be parameterised — table and column names — so never build those from user input; use an allow-list instead. And prepared statements do nothing for XSS, command injection, or SSRF: each context (HTML, the shell, an outbound URL) needs its own defence. Bind values, allow-list identifiers, and escape per context.
Q: Why can't I just use md5() or sha1() for passwords?
MD5 and SHA-1 are fast, general-purpose checksums — a modern GPU computes billions of them per second, so a stolen database of MD5 hashes is cracked almost instantly. Password hashing must be deliberately slow and salted. password_hash() uses bcrypt or Argon2, adds a random salt for you, and lets you raise the cost over time. Always pair it with password_verify(); never compare hashes yourself.
Q: What do the httponly, secure, and samesite cookie flags actually do?
They lock down the session cookie. httponly hides the cookie from JavaScript, so an XSS bug can't read it and steal the session. secure makes the browser send the cookie only over HTTPS, so it can't be sniffed on plain HTTP. samesite=Strict (or Lax) stops the cookie being attached to cross-site requests, which blunts CSRF. Set all three via session_set_cookie_params() before session_start().
Q: What is SSRF and why should a PHP developer care?
SSRF (Server-Side Request Forgery) happens when your server fetches a URL that the user supplied. If you call file_get_contents($_GET['url']) without checking it, an attacker can point it at internal addresses like http://169.254.169.254/ (cloud metadata) or http://localhost/admin to reach things only your server can see. Defend by allow-listing the hosts you intend to call and rejecting private, loopback, and link-local IP ranges.
Mini-Challenge: A Safe Comment Handler
No code is filled in this time — just a brief and an outline. Write it yourself, run it on onecompiler.com/php or your own machine, then check your result against the expected output in the comments. This combines CSRF checking and XSS escaping — the two defences you'll wire into almost every form you ever build.
<?php
// 🎯 MINI-CHALLENGE: a safe "post a comment" handler.
// No code is filled in — work from the steps, then run it.
//
// 1. Pretend the form sent these (already provided):
// \$name = "Bobby <b>Tables</b>";
// \$token = "expected-token"; // the session's CSRF token
// \$sent = "expected-token"; // the token the form submitted
// 2. Reject the request unless the tokens match — use hash_equals(\$token, \$sent).
// If they don't match, echo "Rejected: bad CSRF token" and stop.
// 3. If they match, escape the name with
// htmlspecialchars(\$name, ENT_QUOTES, 'UTF-8')
// and echo: Saved comment from: <escaped name>
//
// Tip: store the comment with a prepared statement (? placeholder), never by
// concatenating \$name into the SQL.
//
// ✅ Expected output (tokens match):
// Saved comment from: Bobby <b>Tables</b>
// your code herehash_equals() matches the tokens, then escape the name with htmlspecialchars() before echoing it.🎉 Lesson Complete!
- ✅ SQL injection → use prepared statements with
?placeholders; never concatenate input into SQL - ✅ XSS → escape output with
htmlspecialchars()and add a Content-Security-Policy - ✅ CSRF → per-session token checked with
hash_equals() - ✅ Passwords →
password_hash()/password_verify(), never md5/sha1 - ✅ Sessions & cookies →
httponly,secure,samesite, andsession_regenerate_id() - ✅ Uploads, SSRF, headers → check MIME + rename, allow-list URLs, send security headers — and never trust user input
- ✅ Next lesson: APIs & JSON — build and consume REST endpoints, applying these same input-trust rules to JSON payloads
Sign up for free to track which lessons you've completed and get learning reminders.