PHP Csrf Protection

Cross-Site Request Forgery (CSRF) is an attack where a malicious web page tricks a logged-in user’s browser into sending a request to your site that the user never intended — for example, silently submitting a "change email" or "delete account" form while the victim is simply browsing an unrelated page. Because browsers automatically attach cookies, including session cookies, to every request sent to a domain, the server sees what looks like a perfectly authenticated request. CSRF protection defeats this by requiring every state-changing request to carry a secret, unpredictable token that the attacker’s page has no way of knowing or fetching.

Overview: How CSRF Protection Works

A CSRF token is a random value the server generates and stores server-side (almost always in the PHP session), then embeds into every form or AJAX request the legitimate page renders. When the form is submitted, the server compares the token that came back in the request against the one stored in the session. If they match, the request is treated as originating from a page the server itself served, because only that page, running in the victim’s own browser, ever saw the token. An attacker’s cross-origin page can trick the browser into sending cookies automatically, but it cannot read the token out of your HTML (the browser’s same-origin policy blocks that), and it cannot guess a properly random 256-bit value.

This is why CSRF is fundamentally a cookie problem, not a login problem: the vulnerability exists because cookies are sent automatically regardless of which site initiated the request. Anything that requires proving "I actually saw the real page" — a hidden form field, a custom request header set by JavaScript that read the token out of the DOM, or a value copied from non-cookie storage — closes the gap, because a forged request from the attacker’s origin can never legitimately contain the correct value.

Internally, PHP’s session mechanism is what makes this practical. session_start() loads (or creates) the $_SESSION array, which persists across requests for the same visitor via a session cookie (usually PHPSESSID). Storing the token in $_SESSION ties it to that specific visitor’s server-side state, so even if two users requested the same page at the same instant, each would receive and need to return their own distinct token. The token itself should be generated with random_bytes(), which pulls from the operating system’s cryptographically secure random number generator (CSPRNG) — never rand(), mt_rand(), or uniqid(), none of which are designed to resist an attacker trying to predict past or future output.

Synchronizer Token vs. Double-Submit Cookie

There are two common architectures. The synchronizer token pattern (used throughout this lesson) stores one token per session, or one per form for extra isolation, server-side, and compares it on submit. The double-submit cookie pattern stores the token in a cookie and also duplicates it in the request body or a header; the server just checks that the two match, which avoids needing server-side session storage — useful for stateless APIs, though it is a weaker guarantee than a session-bound token if an attacker can set cookies on your domain through a subdomain vulnerability. For most PHP applications that already use sessions, the synchronizer pattern is simpler and stronger, so it’s the default to reach for.

CSRF tokens should be paired with, not replaced by, the SameSite cookie attribute. Setting session.cookie_samesite to Lax or Strict in php.ini, or via session_set_cookie_params(), tells the browser not to send the session cookie on many cross-site requests — a strong second layer of defense that costs nothing to add. It does not cover every browser and embedding scenario on its own, so explicit tokens remain the primary defense.

Syntax

The pattern always has three moving parts: generate, embed, and verify.

<?php
session_start();

// Generate once per session
if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

// For this printed example, pretend the token above resolved to a fixed value
$token = 'f3c1a9d7e5b3';

// Embed into the form
echo '<input type="hidden" name="csrf_token" value="' . $token . '">' . "\n";

// Verify on submission
$submitted = 'f3c1a9d7e5b3';
echo hash_equals($token, $submitted) ? "Request verified.\n" : "Request rejected.\n";

Output:

<input type="hidden" name="csrf_token" value="f3c1a9d7e5b3">
Request verified.
Function Purpose
random_bytes(int $length) Returns cryptographically secure random bytes — the correct source for a token.
bin2hex(string $data) Converts raw bytes into a printable hex string, safe to embed in HTML or URLs.
hash_equals(string $known, string $user) Compares two strings in constant time, immune to timing attacks. Always use this for token comparison, never ==.
session_start() Opens or resumes the session so $_SESSION persists the token across requests.
htmlspecialchars() Escapes the token before printing it into HTML so it can never break out of the attribute.

Examples

Example 1: Generating and storing a token

<?php
session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

$token = $_SESSION['csrf_token'];

echo "Token stored in session.\n";
echo "Token length: " . strlen($token) . " characters\n";

Output:

Token stored in session.
Token length: 64 characters

The first request creates the token once, using empty() to avoid overwriting an existing one on subsequent page loads. random_bytes(32) produces 32 raw, unpredictable bytes; bin2hex() converts each byte into two hex characters, so the printable token is always exactly 64 characters long, regardless of its actual random content. That token now lives in $_SESSION until it is rotated or the session ends.

Example 2: Verifying a submitted token

<?php
session_start();

function csrf_token(): string
{
    if (empty($_SESSION['csrf_token'])) {
        $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
    }
    return $_SESSION['csrf_token'];
}

function csrf_verify(?string $submitted): bool
{
    if (!isset($_SESSION['csrf_token']) || $submitted === null) {
        return false;
    }
    return hash_equals($_SESSION['csrf_token'], $submitted);
}

// Simulate a session that already has a token, and a matching submission
$_SESSION['csrf_token'] = 'a1b2c3d4e5f6';
$_POST['csrf_token'] = 'a1b2c3d4e5f6';

$valid = csrf_verify($_POST['csrf_token'] ?? null);
echo $valid ? "Request accepted.\n" : "Request rejected: invalid CSRF token.\n";

// Simulate a forged request with no token at all
unset($_POST['csrf_token']);
$forged = csrf_verify($_POST['csrf_token'] ?? null);
echo $forged ? "Request accepted.\n" : "Request rejected: invalid CSRF token.\n";

Output:

Request accepted.
Request rejected: invalid CSRF token.

csrf_verify() is written defensively: it returns false immediately if the session never stored a token, or if nothing was submitted, before it ever calls hash_equals(). The nullsafe-style ?? null guards against an undefined $_POST key. The second call demonstrates the exact scenario an attacker’s forged request produces — no token at all — and it is correctly rejected.

Example 3: A reusable CsrfGuard class with one-time tokens

<?php
final class CsrfGuard
{
    private const SESSION_KEY = 'csrf_token';

    public static function token(): string
    {
        if (empty($_SESSION[self::SESSION_KEY])) {
            $_SESSION[self::SESSION_KEY] = bin2hex(random_bytes(32));
        }
        return $_SESSION[self::SESSION_KEY];
    }

    public static function field(): string
    {
        $token = htmlspecialchars(self::token(), ENT_QUOTES, 'UTF-8');
        return '<input type="hidden" name="csrf_token" value="' . $token . '">';
    }

    public static function verify(?string $submitted): bool
    {
        $valid = isset($_SESSION[self::SESSION_KEY])
            && $submitted !== null
            && hash_equals($_SESSION[self::SESSION_KEY], $submitted);

        if ($valid) {
            unset($_SESSION[self::SESSION_KEY]);
        }

        return $valid;
    }
}

session_start();

// Simulate a token that was already generated earlier in this session
$_SESSION['csrf_token'] = 'deadbeefcafebabe1234567890abcdef1234567890abcdef1234567890abcd';

echo CsrfGuard::field() . "\n";

$_POST['csrf_token'] = $_SESSION['csrf_token'];

echo CsrfGuard::verify($_POST['csrf_token']) ? "First submit: accepted\n" : "First submit: rejected\n";
echo CsrfGuard::verify($_POST['csrf_token']) ? "Replay: accepted\n" : "Replay: rejected\n";

Output:

<input type="hidden" name="csrf_token" value="deadbeefcafebabe1234567890abcdef1234567890abcdef1234567890abcd">
First submit: accepted
Replay: rejected

CsrfGuard bundles the whole lifecycle into one class: token() generates or reuses the session value, field() renders a safely escaped hidden input, and verify() checks the submission with hash_equals() and then unset()s the token on success. That last step implements a one-time-use token: the second call to verify() fails because isset() is now false, so a captured or replayed request cannot be resubmitted even if an attacker manages to intercept it.

How It Works Step by Step

  1. The browser requests the form page with a normal GET request.
  2. PHP starts the session and checks whether a token already exists; if not, it generates one with random_bytes() and stores it in $_SESSION.
  3. PHP renders the page, embedding the token as a hidden input (or exposing it to JavaScript via a meta tag for AJAX use), and sends the HTML to the browser.
  4. The user submits the form. The browser automatically attaches the session cookie and also sends the hidden field’s value in the POST body.
  5. On the new request, PHP calls session_start() again; because the same session cookie comes back, PHP loads the same $_SESSION data, including the stored token.
  6. PHP reads the submitted token from $_POST (or a custom header for AJAX) and compares it against the session’s stored token using hash_equals().
  7. If they match, the request is processed, and, for sensitive actions, the token may be rotated or unset so it cannot be replayed.
  8. If they don’t match, or the token is missing entirely, the request is rejected before any business logic runs — typically with an HTTP 403 response.

Common Mistakes

Mistake 1: Comparing tokens with == instead of hash_equals()

<?php
session_start();

$_SESSION['csrf_token'] = 'a1b2c3d4e5f6';
$_POST['csrf_token'] = 'a1b2c3d4e5f6';

$submitted = $_POST['csrf_token'] ?? '';

if ($submitted == $_SESSION['csrf_token']) {
    echo "Token matches, request allowed.\n";
} else {
    echo "Token mismatch, request rejected.\n";
}

This happens to work in the example above, but == is the wrong tool for comparing secrets. It is not constant-time, meaning the comparison can return a fraction of a microsecond faster or slower depending on how many leading characters match, which in theory lets an attacker recover the token byte-by-byte by measuring response times over many requests. It also performs loose type juggling: two strings that both look numeric can be considered equal by == even when their characters differ. Neither risk exists with the constant-time, type-strict hash_equals().

Corrected:

<?php
session_start();

$_SESSION['csrf_token'] = 'a1b2c3d4e5f6';
$_POST['csrf_token'] = 'a1b2c3d4e5f6';

$submitted = $_POST['csrf_token'] ?? '';

if (hash_equals($_SESSION['csrf_token'], $submitted)) {
    echo "Token matches, request allowed.\n";
} else {
    echo "Token mismatch, request rejected.\n";
}

Mistake 2: Generating the token with a predictable function

<?php
session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = uniqid();
}

echo "Token generated using uniqid().\n";

uniqid() is based on the current time in microseconds; it is not random at all in the cryptographic sense, and an attacker who can narrow down when a session started can brute-force or guess the value. The same problem applies to rand() and mt_rand(), which are fast, predictable pseudo-random generators meant for simulations and games, not security tokens.

Corrected:

<?php
session_start();

if (empty($_SESSION['csrf_token'])) {
    $_SESSION['csrf_token'] = bin2hex(random_bytes(32));
}

echo "Token generated using random_bytes().\n";

Mistake 3: Protecting forms but forgetting AJAX and fetch requests

It’s common to add a hidden csrf_token field to every <form> and then forget that JavaScript-driven actions — a "delete" button wired to fetch(), an inline edit that calls an API endpoint with XMLHttpRequest — are just as capable of changing state, and are just as forgeable. If even one mutating endpoint skips verification, the whole protection scheme is only as strong as its weakest unprotected route. The fix is to expose the token to JavaScript (for example via a <meta> tag read by your script) and send it as a custom header or JSON field on every AJAX call, then verify it server-side with the exact same hash_equals() check used for regular form posts.

Best Practices

  • Generate tokens with random_bytes() and bin2hex(); never use rand(), mt_rand(), or uniqid().
  • Always compare tokens with hash_equals(), never == or ===.
  • Store the token server-side in $_SESSION, one per session (or one per form for extra isolation in multi-tab workflows).
  • Protect every state-changing request — POST, PUT, PATCH, DELETE, and AJAX/fetch calls — not just <form> submissions.
  • Rotate the token after it is successfully used for sensitive actions (password changes, payments) to limit the replay window.
  • Set SameSite=Lax or Strict, plus HttpOnly and Secure, on session cookies as defense-in-depth — never as a replacement for tokens.
  • Escape the token with htmlspecialchars() whenever you print it into HTML.
  • For JSON APIs, send the token as a custom header (for example X-CSRF-Token) rather than relying on a form field.
  • Never rely on a token passed only in a GET query string for long-lived links — GET requests are logged, cached, and pre-fetched, which can leak the token.
  • Fail closed: if a token is missing or doesn’t match, reject the request outright instead of falling back to some default behavior.

Practice Exercises

1. Write a function generate_csrf_token(): string that returns a session-stored token, creating one only if it doesn’t already exist, plus a companion verify_csrf_token(?string $submitted): bool that checks it safely with hash_equals().

2. Extend a login form’s token handling so that, immediately after a successful login, the token is unset from the session and a fresh one is generated for the next page — preventing the exact same token from being replayed on a second request. Expected behavior: verifying the same submitted value twice in a row should return true the first time and false the second.

3. An AJAX-based "delete comment" button currently sends no CSRF token at all. Describe (or write) the changes needed on both the JavaScript side (how the token reaches the request) and the PHP endpoint (how it is verified) to bring this action in line with the rest of the site’s CSRF protection.

Summary

  • CSRF exploits the fact that browsers automatically send cookies with every request, regardless of which site initiated it.
  • A CSRF token is a secret, unpredictable value tied to the user’s session that an attacker’s page cannot obtain or guess.
  • Generate tokens with random_bytes() and bin2hex(), never with rand(), mt_rand(), or uniqid().
  • Always verify tokens with hash_equals() to avoid timing attacks and type-juggling bugs.
  • Protect every state-changing request, including AJAX and fetch calls, not just HTML forms.
  • Combine tokens with SameSite cookies for layered defense, but never rely on SameSite alone.
  • Rotate or unset tokens after successful use on sensitive actions, and always fail closed on a missing or mismatched token.