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
<?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
Output
$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
<?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";
}
Output
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 transfer.php
<?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>
HTTP
POST /transfer.php amount=500
Responses
> 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_bytes generates cryptographically secure random data — never use rand() or uniqid() for secrets.
  • hash_equals compares 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 transfer.php
<?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']);
HTTP
GET /transfer.php
POST /transfer.php amount=500&csrf=from-form
POST /transfer.php amount=500&csrf=guessed-token
Responses
> 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
<?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)));
Output
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

ThreatDefenceLesson
SQL injectionPrepared statements for every query with outside data; whitelist identifiers58
Cross-site scripting (XSS)Escape all output with htmlspecialchars (or an auto-escaping template engine); Content-Security-Policy header47, 50
CSRFTokens on state-changing forms; SameSite cookies; no changes on GET59
Stolen passwordspassword_hash/password_verify; rate-limit login attempts59
Session hijackingHTTPS everywhere; Secure, HttpOnly, SameSite cookies; session_regenerate_id on login48
Malicious uploadsCheck real type with finfo; random file names; store outside the web root51
Path traversalNever build file paths from raw input; basename or lookups by ID54
Open redirectsRedirect only to known URLs or your own paths50
Information leaksdisplay_errors = Off in production; log errors instead; generic error pages63
Vulnerable dependenciesKeep PHP and packages updated; composer audit60

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; use json_decode for data from users or other systems.
  • system(), exec(), shell_exec() run shell commands; if you must pass user data, escape every argument with escapeshellarg().
  • include/require with 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.

Try it yourself · main.php
Edit the code, copy it into main.php, then run it with php main.php (PHP 8.5). How to install PHP
Output
Welcome back, ana@example.com!
Invalid email or password.
Invalid email or password.