CSRF Tokens

A CSRF token proves a state-changing request came from your own form; compare it with hash_equals, not with ===.

Syntaxhash_equals(string $known_string, string $user_string): bool

Cross-site request forgery works like this. You are signed in to your bank in one tab. Another page contains a form that posts to the bank's transfer URL. The browser attaches your session cookie, because it attaches it to every request to that host, whoever started it. The bank sees a properly authenticated request. The cookie proves who you are. It does not prove that you asked.

A token the other site cannot read

Put one random value in the session, bin2hex(random_bytes(32)), and a copy of it in a hidden field in every form that changes something. On submission, compare the two. An attacker's page can make your browser send a request, but the browser's same-origin rules stop that page from reading yours to find the token.

Compare with hash_equals

hash_equals() compares two strings in constant time, so an attacker cannot measure how many leading characters were right. Use it for every secret comparison, not ===. Tokens belong on state-changing requests only; a GET should never change anything, so there is nothing to protect.

SameSite is not the whole answer

A cookie set with samesite=Lax is not sent on a cross-site POST, which blocks the plain version of this attack. Set it, but treat it as a second lock. Subdomains count as the same site, so an attacker who controls one is inside the fence, and anything that needs SameSite=None gives the protection up entirely.

Example

Try it yourself
Loading editor…
Press Run to execute the code.

When to use it

  • Protect a delete-account button from a forged POST.
  • Add a token to every form in a plain-PHP admin panel.
  • Validate a token that JavaScript sent in a request header.
  • Replace an == token comparison with a constant-time one.

More examples

Issuing the token and putting it in the form

One token per session, made the first time a form is rendered and reused afterwards. ??= assigns only when the key is unset, so a refresh does not invalidate a form left open in another tab. random_bytes() is the cryptographic source; rand() and uniqid() are guessable and must not be used here.

Example · php
<?php

declare(strict_types=1);

session_start();

// Shorthand. The longhand is:
// if (!isset($_SESSION['csrf'])) { $_SESSION['csrf'] = bin2hex(random_bytes(32)); }
$_SESSION['csrf'] ??= bin2hex(random_bytes(32));

?>
<form method="post" action="/transfer.php">
    <input type="hidden" name="_token" value="<?= htmlspecialchars($_SESSION['csrf'], ENT_QUOTES) ?>">
    <input type="text" name="amount" inputmode="decimal">
    <button type="submit">Send</button>
</form>

Checking it before anything changes

The handler side. Both guards run before any work: the method check, then the token. The ?? '' on each side is what keeps a missing field or an expired session from becoming a TypeError, and the is_string() test handles _token arriving as an array, which it does if someone sends _token[] in the request.

Example · php
<?php

declare(strict_types=1);

session_start();

if ($_SERVER['REQUEST_METHOD'] !== 'POST') {
    http_response_code(405);
    header('Allow: POST');
    exit('Use POST.');
}

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

if (!is_string($submitted) || !hash_equals($_SESSION['csrf'] ?? '', $submitted)) {
    http_response_code(400);
    exit('Bad request.');
}

// Only now does the money move.
$stmt = $pdo->prepare('UPDATE accounts SET balance = balance - ? WHERE user_id = ?');
$stmt->execute([$amount, $_SESSION['user_id']]);

The same check for a fetch() request

JavaScript that posts JSON needs the same protection, and gets it through a header rather than a form field. PHP turns any request header into a $_SERVER key by upper-casing it, replacing dashes with underscores and prefixing HTTP_. The check is otherwise identical, and the error comes back as JSON because the caller is expecting JSON.

Example · php
<?php

declare(strict_types=1);

session_start();

// A fetch() call cannot carry a hidden form field, so the page sends the
// token in a request header instead. PHP exposes X-CSRF-Token as
// $_SERVER['HTTP_X_CSRF_TOKEN'].
$submitted = $_SERVER['HTTP_X_CSRF_TOKEN'] ?? '';

if (!hash_equals($_SESSION['csrf'] ?? '', $submitted)) {
    http_response_code(400);
    header('Content-Type: application/json');
    exit(json_encode(['error' => 'csrf_failed']));
}

Discussion

  • Be the first to comment on this lesson.