SEC-003
CSRF tokens without a framework
One token per session, compared in constant time, checked on every state-changing POST. Twenty lines and no dependency.
A form on someone else's site can post to yours, and the browser will attach your user's cookies. Without a token, any POST endpoint is callable by any page on the internet that your user visits while logged in.
A per-session random token, rendered into every form and compared on every POST, stops that: the attacker's page cannot read the token because the same-origin policy will not let it.
Compare with hash_equals rather than a plain equality check. String comparison in PHP short-circuits on the first differing byte, which leaks how much of a guess was correct through timing. It is a narrow attack and a one-word fix.
function csrf_token(): string
{
if (empty($_SESSION['csrf'])) {
$_SESSION['csrf'] = bin2hex(random_bytes(16));
}
return $_SESSION['csrf'];
}
function csrf_field(): string
{
return '<input type="hidden" name="_csrf" value="'
. htmlspecialchars(csrf_token(), ENT_QUOTES) . '">';
}
function csrf_check(): void
{
if (($_SERVER['REQUEST_METHOD'] ?? 'GET') !== 'POST') {
return;
}
$sent = (string) ($_POST['_csrf'] ?? '');
// hash_equals, not ===. Constant time comparison.
if ($sent === '' || !hash_equals((string) ($_SESSION['csrf'] ?? ''), $sent)) {
http_response_code(400);
exit('Session expired. Go back, reload the page and try again.');
}
}
Using it
Call csrf_check as the first line of every POST handler, before you read any input. Put it in a shared bootstrap so a new form cannot be added without it.
Keep the token for the whole session rather than rotating per form. Per-form tokens break the back button and multiple tabs, and buy very little.
What bites people
The failure message should tell the user what to do. "Invalid token" produces support tickets; "reload the page and try again" does not.
Endpoints called by machines — postbacks, APIs — cannot present a session token. Authenticate those with a signature or a key, and exempt them explicitly rather than dropping the check globally.