Web Security Essentials
A PHP application is exposed to everyone on the internet, including people trying to break it. Most attacks exploit a handful of well-known mistakes. This lesson brings the defences together: storing passwords safely, preventing cross-site request forgery, and a checklist covering injection, XSS, sessions, uploads and configuration.
Storing passwords
Never store passwords as plain text, and never use fast hashes like md5 or sha1 for them — attackers can test billions of guesses per second against those. password_hash uses a deliberately slow algorithm with a random salt, and password_verify checks a login attempt:
<?php $hash = password_hash('correct horse battery staple', PASSWORD_DEFAULT); echo substr($hash, 0, 7), "... (", strlen($hash), " characters)\n"; var_dump(password_verify('correct horse battery staple', $hash)); var_dump(password_verify('Correct horse battery staple', $hash)); $again = password_hash('correct horse battery staple', PASSWORD_DEFAULT); var_dump($hash === $again); // different salt every time
$2y$12$... (60 characters) bool(true) bool(false) bool(false)
The hash string records the algorithm and cost ($2y$12$ is bcrypt with cost 12), so password_verify needs nothing else. Store it in a column of at least 255 characters, since future algorithms may produce longer hashes.
As hardware gets faster, PHP raises the default cost. password_needs_rehash tells you when an old hash should be upgraded — check it after each successful login:
<?php $oldHash = password_hash('secret', PASSWORD_BCRYPT, ['cost' => 10]); if (password_verify('secret', $oldHash) && password_needs_rehash($oldHash, PASSWORD_DEFAULT)) { $newHash = password_hash('secret', PASSWORD_DEFAULT); echo "Upgraded from cost ", password_get_info($oldHash)['options']['cost'], " to ", password_get_info($newHash)['options']['cost'], "\n"; }
Upgraded from cost 10 to 12
Cross-site request forgery (CSRF)
If a user is logged in to your site, their browser sends the session cookie with every request to it — including requests triggered by a malicious page on another site, such as a hidden form that posts to /account/delete. CSRF protection makes every state-changing form include a secret token that other sites can't know:
<?php session_start(); $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); if ($_SERVER['REQUEST_METHOD'] === 'POST') { if (!hash_equals($_SESSION['csrf'], $_POST['csrf'] ?? '')) { http_response_code(403); exit('Invalid request (CSRF token mismatch)'); } exit('Transfer of ' . (int) $_POST['amount'] . ' completed'); } ?> <form method="post"> <input type="hidden" name="csrf" value="<?= $_SESSION['csrf'] ?>"> <input name="amount"><button>Send</button> </form>
POST /transfer.php amount=500
> POST /transfer.php < 403 Forbidden Invalid request (CSRF token mismatch)
The forged request has the session cookie but not the token, so it's refused. A real form includes the token as a hidden field, and every POST handler checks it. Notes:
random_bytesgenerates cryptographically secure random data — never userand()oruniqid()for secrets.hash_equalscompares in constant time, so attackers can't learn the token from response timing.- Setting session cookies to
SameSite=Lax(lesson 49) blocks most cross-site POSTs as an extra layer. - Never change data on a GET request — links and images can trigger GETs from anywhere.
Here's the same endpoint accepting a request that includes the right token, simulated by first loading the form and then posting with the token from the session:
<?php session_start(); $_SESSION['csrf'] ??= bin2hex(random_bytes(32)); if ($_SERVER['REQUEST_METHOD'] === 'POST') { $token = $_POST['csrf'] ?? ''; if ($token === 'from-form') { $token = $_SESSION['csrf']; // demo only: stands in for the hidden field's value } if (!hash_equals($_SESSION['csrf'], $token)) { http_response_code(403); exit('Invalid request (CSRF token mismatch)'); } exit('Transfer of ' . (int) $_POST['amount'] . ' completed'); } echo 'form shown, token length ', strlen($_SESSION['csrf']);
GET /transfer.php POST /transfer.php amount=500&csrf=from-form POST /transfer.php amount=500&csrf=guessed-token
> GET /transfer.php form shown, token length 64 > POST /transfer.php Transfer of 500 completed > POST /transfer.php < 403 Forbidden Invalid request (CSRF token mismatch)
Frameworks generate and check these tokens automatically (Laravel's @csrf, Symfony's form component).
Secure random values and tokens
<?php $token = bin2hex(random_bytes(16)); // 32 hex characters $pin = random_int(100000, 999999); // 6-digit code $apiKey = rtrim(strtr(base64_encode(random_bytes(24)), '+/', '-_'), '='); echo strlen($token), " ", strlen((string) $pin), " ", strlen($apiKey), "\n"; $secret = 'server-side-secret-key'; $signature = hash_hmac('sha256', 'user=42', $secret); var_dump(hash_equals($signature, hash_hmac('sha256', 'user=42', $secret))); var_dump(hash_equals($signature, hash_hmac('sha256', 'user=1', $secret)));
32 6 32 bool(true) bool(false)
hash_hmac creates a signature that proves data wasn't tampered with — useful for password-reset links or signed cookies. Store only a hash of long-lived tokens like reset tokens in the database, just as with passwords.
The security checklist
| Threat | Defence | Lesson |
|---|---|---|
| SQL injection | Prepared statements for every query with outside data; whitelist identifiers | 58 |
| Cross-site scripting (XSS) | Escape all output with htmlspecialchars (or an auto-escaping template engine); Content-Security-Policy header | 47, 50 |
| CSRF | Tokens on state-changing forms; SameSite cookies; no changes on GET | 59 |
| Stolen passwords | password_hash/password_verify; rate-limit login attempts | 59 |
| Session hijacking | HTTPS everywhere; Secure, HttpOnly, SameSite cookies; session_regenerate_id on login | 48 |
| Malicious uploads | Check real type with finfo; random file names; store outside the web root | 51 |
| Path traversal | Never build file paths from raw input; basename or lookups by ID | 54 |
| Open redirects | Redirect only to known URLs or your own paths | 50 |
| Information leaks | display_errors = Off in production; log errors instead; generic error pages | 63 |
| Vulnerable dependencies | Keep PHP and packages updated; composer audit | 60 |
Other dangerous functions
eval()runs a string as PHP code — never with any outside data, and rarely at all.unserialize()on untrusted data can create arbitrary objects; usejson_decodefor data from users or other systems.system(),exec(),shell_exec()run shell commands; if you must pass user data, escape every argument withescapeshellarg().include/requirewith a path built from user input can run arbitrary files.
Try it yourself
Build a minimal login check: hash a password once, then verify a few login attempts, giving the same message for an unknown user and a wrong password.
Welcome back, ana@example.com! Invalid email or password. Invalid email or password.