CSRF Tokens
A CSRF token proves a state-changing request came from your own form; compare it with hash_equals, not with ===.
hash_equals(string $known_string, string $user_string): boolCross-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
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.
<?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.
<?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.
<?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