Security in Depth

Course 3 · Ch 5
Security in Depth: CSRF, XSS Prevention, Password Hashing
Closing the last major gap left open since Intermediate — forged requests from a different site entirely

XSS (Intermediate Chapter 8) and password hashing (Intermediate Chapter 5) have already been covered properly. This chapter revisits both briefly as a security-focused recap, then introduces CSRF — the one major web vulnerability category not yet addressed anywhere in this series.

CSRF — Cross-Site Request Forgery

Imagine a logged-in visitor (with a valid session, Intermediate Chapter 5) visits a malicious page that auto-submits a hidden form to yoursite.com/transfer-money. Because the visitor's browser automatically attaches their session cookie to any request to that domain, the malicious request looks completely legitimate to the server — even though the visitor never intended to submit it.

Logged-in visitor visits malicious-site.com auto-submits hidden form yoursite.com + valid session cookie Action performed!
The session cookie alone isn't enough proof the visitor actually intended this specific request

The Fix — CSRF Tokens

<?php session_start(); if (empty($_SESSION['csrf_token'])) { $_SESSION['csrf_token'] = bin2hex(random_bytes(32)); // a long, unguessable random value } ?> <form method="post" action="transfer.php"> <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>"> <!-- real form fields here --> <input type="submit"> </form>
transfer.php
<?php session_start(); $submittedToken = $_POST['csrf_token'] ?? ''; if (!hash_equals($_SESSION['csrf_token'] ?? '', $submittedToken)) { http_response_code(403); die("Invalid request."); } // token is valid — proceed with the actual transfer logic ?>

The token is embedded in the legitimate form, and re-checked on submission. The malicious page from the diagram above has no way to know or guess this token — it isn't part of the session cookie the browser sends automatically, so the forged request fails the check.

hash_equals() — comparing tokens, not just ==
hash_equals() performs a "timing-safe" comparison — a regular === string comparison can, in theory, take very slightly different amounts of time depending on how many leading characters match, which an attacker could measure over many attempts to slowly guess a secret value. hash_equals() is specifically designed to take the same amount of time regardless, which matters for comparing security tokens (though, with a 32-byte random token, brute-forcing is already practically infeasible regardless).

XSS Prevention — A Quick Recap

<?php echo htmlspecialchars($userInput); // escape on EVERY output, every time (Intermediate Chapter 8) ?>

Nothing new here beyond Intermediate Chapter 8's rule — included as a reminder that CSRF and XSS are entirely separate vulnerabilities requiring entirely separate fixes; defending against one does nothing for the other.

Password Hashing — A Quick Recap, Plus One Addition

<?php $hash = password_hash($password, PASSWORD_DEFAULT); // Intermediate Chapter 5 // NEW: checking if a hash needs rehashing (e.g. PHP's default algorithm/cost improved) if (password_verify($password, $storedHash) && password_needs_rehash($storedHash, PASSWORD_DEFAULT)) { $newHash = password_hash($password, PASSWORD_DEFAULT); // save $newHash to the database, replacing $storedHash } ?>

password_needs_rehash() detects when a stored hash was created with weaker settings than PHP's current default — checked at login time (when the plain password is briefly available anyway), letting old hashes be quietly upgraded over time as users log in, without forcing a mass password reset.

A Combined Security Checklist

  • Escape every piece of output with htmlspecialchars() — prevents XSS
  • Use prepared statements for every database query — prevents SQL injection
  • Include and verify a CSRF token on every state-changing form — prevents CSRF
  • Hash passwords with password_hash(), verify with password_verify() — never store plain text
  • Validate and sanitise file uploads, never trust the claimed MIME type — prevents malicious uploads
  • Always call exit immediately after a header() redirect — prevents protected content leaking

Coding Challenges

Challenge 1

Write a complete CSRF-protected form flow: a page generating and embedding a token, and a processing script that rejects the request with a 403 status if the token is missing or doesn't match, using hash_equals(). Test it conceptually by describing what would happen if an attacker's page tried to submit the same form action directly, without the token.

📄 View solution
Challenge 2

Write a function verifyAndUpgradePassword(string $password, string &$storedHash): bool that returns false immediately if the password doesn't verify, otherwise checks password_needs_rehash() and updates $storedHash (passed by reference) with a freshly generated hash if needed, then returns true. Explain in a comment why $storedHash needs to be passed by reference here.

📄 View solution
Challenge 3

Review this code and list every security issue you can find, then write a corrected version: session_start(); $name = $_POST['name']; echo "<form method='post'><input name='name' value='$name'><input type='submit'></form>"; $stmt = $pdo->query("SELECT * FROM users WHERE name = '$name'");

📄 View solution

Chapter 5 Quick Reference

  • CSRF — a forged request riding on a visitor's valid session, from a different malicious site
  • CSRF token — a random, unguessable value embedded in forms and re-checked on submission
  • hash_equals() — timing-safe comparison, used for comparing security tokens
  • htmlspecialchars() — XSS prevention, on every output (Intermediate Chapter 8 recap)
  • password_hash() / password_verify() — never store plain-text passwords (Intermediate Chapter 5 recap)
  • password_needs_rehash() — detects outdated hash settings, allows quiet upgrades at login time
  • CSRF, XSS, and SQL injection are three SEPARATE vulnerabilities — each needs its own specific defence
  • Next chapter: performance — opcache, profiling, caching strategies