The request we never made
Last quarter I ran a scheduled audit of the moderation log on our admin panel and found 41 region_block actions I could not account for. No admin session was active at those timestamps. The IP in the audit row was a real editor's IP. The user agent was a real browser.
The cause turned out to be embarrassingly small. Our CSRF guard was one line: if (!empty($_POST) && !hash_equals($_SESSION['csrf'] ?? '', $_POST['csrf'] ?? '')) { http_response_code(403); exit; }
Read it carefully. The guard only fires when $_POST is non-empty. Our older HTML forms posted application/x-www-form-urlencoded, so they populated $_POST and got checked. But every endpoint we had added in the previous six months — bulk approve, bulk region-block, the trend-score recompute trigger — was called from JavaScript with Content-Type: application/json. PHP does not populate $_POST for JSON bodies. $_POST was empty. The guard was skipped. Those endpoints had been completely unprotected since the day they shipped, and a page on any domain could fire them at a logged-in editor's browser.
I rebuilt the whole thing around signed double-submit cookies rather than patching the conditional, and this post is the implementation that now runs in production on ViralVidVault — PHP 8.4, SQLite in WAL mode, LiteSpeed, with Cloudflare Workers in front. The code below is what is actually deployed, with the secret handling and route names changed.
Why we dropped session-stored tokens
The obvious fix is $_SESSION['csrf'] done properly: enforce on every unsafe method, compare with hash_equals, done. That is a correct design and for a lot of apps it is the right one. We moved off it for four specific reasons.
-
PHP's session lock serializes an admin's own tabs. The default file handler holds an exclusive lock from
session_start()to script end. A bulk purge over 6,000 rows takes 8–12 seconds on our box. During that window every other tab that admin has open blocks onsession_start(). Editors thought the panel had crashed. Moving sessions into SQLite made it worse, not better — the same lock, now competing with the WAL writer. - Four sites, one codebase, no shared state. We deploy the same tree to four domains. Anything that needs server-side state at request time is one more thing that has to be identical, migrated, and backed up on all of them.
- Cached HTML is a token leak. We run a three-layer cache (LiteSpeed page cache, a PHP file page cache, then a data cache). Admin routes are excluded, but "excluded by configuration" is a promise, not a guarantee. A token that is only valid for one session and cryptographically bound to it fails closed if a page ever leaks into a shared cache.
- Data minimisation. Under GDPR the CSRF cookie is a strictly necessary cookie — it is exempt from consent under the ePrivacy "strictly necessary" carve-out, which means it must genuinely be strictly necessary and nothing more. A stateless token that carries only an expiry, a nonce and a MAC is far easier to document in a records-of-processing entry than "an opaque identifier joined server-side to a session table."
What double-submit actually buys you
The classic naive version: generate a random value, put it in a cookie, also put it in the form. On POST, check that the two match. The reasoning is that an attacker's page can cause the cookie to be sent (cookies travel cross-site) but cannot read it, so it cannot put the matching value in the body.
That reasoning has a hole, and it is the reason plain double-submit is no longer recommended on its own. The attacker does not need to read your cookie. They need to write one. Cookies do not respect the same-origin policy for writes the way storage does:
- A compromised or attacker-controlled subdomain can set a cookie with
Domain=example.comthat the apex will send. - A MITM on any plaintext HTTP connection to any host in the registrable domain can inject a
Set-Cookiethat the browser will honour on HTTPS requests, because cookies are not isolated by scheme. - An old, forgotten staging host under the same domain is enough.
Once the attacker can write the cookie, they set it to attacker-chosen-value, put attacker-chosen-value in their forged form, and the equality check passes. This is cookie tossing, and it defeats unsigned double-submit completely.
The fix is that the token has to be unforgeable, not merely matching. Sign it with a server-side secret and verify the signature independently of the equality check. An attacker who can write cookies still cannot produce a value that carries a valid MAC. Two further hardening steps:
-
Use the
__Host-cookie prefix. Browsers refuse a__Host--prefixed cookie unless it isSecure, hasPath=/, and has noDomainattribute. NoDomainmeans no subdomain can set it for the apex. This single prefix kills most of the cookie-tossing surface at the browser level. - Bind the token to the session. Include a hash of the session identifier in the signed payload. A valid token minted for admin A is then useless in admin B's browser, and a token captured from a logged-out session is useless after re-login.
What it does not buy you, and I want to be blunt about this: none of it survives XSS. If an attacker runs script on your origin they read the token out of the DOM and post whatever they like. CSRF defence and XSS defence are separate problems, and a strict CSP is the other half of the job.
HttpOnly, and why we do not let JavaScript read the cookie
Most double-submit write-ups set the cookie without HttpOnly so document.cookie can read it back for fetch. We do not. The cookie is HttpOnly; the server renders the same token into the page as a <meta> tag, and JavaScript reads it from there.
This is still double-submit — the browser sends the cookie automatically, the client sends the token explicitly, the server compares them — but the token never lives in a JS-readable cookie. It also plays better with a strict CSP: the meta tag is part of the document that the server controlled, and there is no code path in which a third-party script pulls a security token out of document.cookie.
Token format
The token is four dot-separated fields — v1, the unix expiry, a base64url nonce, and a base64url MAC — where the MAC is HMAC-SHA256(secret, "v1|" + expiry + "|" + nonce + "|" + sessionBinding).
sessionBinding is hash('sha256', $sessionId . $userId). It is inside the MAC input but not transmitted, so the token leaks nothing about the session. Verification recomputes it from the current request. Expiry is 2 hours with 60 seconds of leeway for clock skew between the app host and whatever generated the page.
The PHP 8.4 implementation
<?php
declare(strict_types=1);
namespace App\Security;
final class Csrf
{
private const string VERSION = 'v1';
private const string COOKIE = '__Host-csrf';
private const string HEADER = 'HTTP_X_CSRF_TOKEN';
private const int TTL = 7200;
private const int LEEWAY = 60;
private const int NONCE_LEN = 24;
public function __construct(
private readonly string $secret,
private readonly bool $secureContext = true,
) {
if (strlen($this->secret) < 32) {
throw new \InvalidArgumentException('CSRF secret must be >= 32 bytes');
}
}
public function issue(string $binding, ?int $now = null): string
{
$now ??= time();
$expiry = $now + self::TTL;
$nonce = $this->b64url(random_bytes(self::NONCE_LEN));
$mac = $this->sign($expiry, $nonce, $binding);
return self::VERSION . '.' . $expiry . '.' . $nonce . '.' . $mac;
}
/** Returns null on success, or a machine-readable failure reason. */
public function verify(string $submitted, string $fromCookie, string $binding, ?int $now = null): ?string
{
$now ??= time();
if ($submitted === '' || $fromCookie === '') {
return 'missing';
}
// Compare the two copies first: cheap, constant-time, catches the common case.
if (!hash_equals($fromCookie, $submitted)) {
return 'mismatch';
}
$parts = explode('.', $submitted);
if (count($parts) !== 4 || $parts[0] !== self::VERSION) {
return 'malformed';
}
[, $expiryRaw, $nonce, $mac] = $parts;
if (!ctype_digit($expiryRaw)) {
return 'malformed';
}
$expiry = (int) $expiryRaw;
// Signature check BEFORE expiry check, so a forged token never
// reaches logic that branches on attacker-controlled values.
if (!hash_equals($this->sign($expiry, $nonce, $binding), $mac)) {
return 'bad_signature';
}
if ($now > $expiry + self::LEEWAY) {
return 'expired';
}
return null;
}
public function sendCookie(string $token): void
{
setcookie($this->cookieName(), $token, [
'expires' => 0, // session cookie; token carries its own expiry
'path' => '/',
'secure' => $this->secureContext,
'httponly' => true,
'samesite' => 'Lax',
]);
}
public function fromRequest(): array
{
$cookie = (string) ($_COOKIE[$this->cookieName()] ?? '');
$submitted = (string) ($_POST['_csrf'] ?? '');
if ($submitted === '') {
$submitted = (string) ($_SERVER[self::HEADER] ?? '');
}
if ($submitted === '' && str_contains((string) ($_SERVER['CONTENT_TYPE'] ?? ''), 'application/json')) {
$body = json_decode((string) file_get_contents('php://input'), true);
$submitted = is_array($body) ? (string) ($body['_csrf'] ?? '') : '';
}
return [$submitted, $cookie];
}
public function cookieName(): string
{
// __Host- requires Secure; on plain-HTTP local dev the browser drops it silently.
return $this->secureContext ? self::COOKIE : 'dev-csrf';
}
private function sign(int $expiry, string $nonce, string $binding): string
{
$payload = self::VERSION . '|' . $expiry . '|' . $nonce . '|' . $binding;
return $this->b64url(hash_hmac('sha256', $payload, $this->secret, true));
}
private function b64url(string $raw): string
{
return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
}
}
Three details worth calling out:
-
verify()checks the signature before the expiry. If you check expiry first you are branching on an integer an attacker fully controls, and you end up with timing and logic differences between "expired" and "forged" that leak information. - The cookie is a session cookie (
expires => 0). The lifetime that matters lives inside the signed payload, where the client cannot extend it. -
fromRequest()deliberately accepts the token from a form field, a header, or a JSON body. That is the whole point of the incident that started this: the guard must work regardless of how the endpoint is called.
Wiring it into the router
The rule is: every state-changing method is checked, and the check is opt-out by explicit allowlist, not opt-in. If a new endpoint forgets to declare itself, it gets protected, not skipped.
<?php
declare(strict_types=1);
use App\Security\Csrf;
const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS', 'TRACE'];
// Endpoints authenticated by something other than a cookie (signed webhook, API key).
const CSRF_EXEMPT = [
'/hooks/indexnow',
'/hooks/cf-purge',
];
function csrfGuard(Csrf $csrf, string $method, string $path, string $binding): void
{
if (in_array($method, SAFE_METHODS, true) || in_array($path, CSRF_EXEMPT, true)) {
return;
}
// Guard against the confusing failure mode: an upload over post_max_size
// arrives with empty $_POST and $_FILES and looks exactly like a CSRF failure.
$declared = (int) ($_SERVER['CONTENT_LENGTH'] ?? 0);
$limit = (int) parseBytes(ini_get('post_max_size') ?: '8M');
if ($declared > 0 && $limit > 0 && $declared > $limit) {
http_response_code(413);
header('Content-Type: application/json');
echo json_encode(['error' => 'payload_too_large', 'limit' => $limit]);
exit;
}
// Defence in depth: the Origin header is set by the browser on every
// cross-site POST and cannot be forged by page script.
$origin = $_SERVER['HTTP_ORIGIN'] ?? null;
if ($origin !== null && !isAllowedOrigin($origin)) {
rejectCsrf('bad_origin');
}
[$submitted, $cookie] = $csrf->fromRequest();
$reason = $csrf->verify($submitted, $cookie, $binding);
if ($reason !== null) {
rejectCsrf($reason);
}
}
function rejectCsrf(string $reason): never
{
// 419 is non-standard but unambiguous for the client: token problem, retry after refresh.
http_response_code($reason === 'expired' ? 419 : 403);
header('Content-Type: application/json');
header('Cache-Control: no-store');
csrfAudit($reason); // rate-limited; see the WAL note below
echo json_encode(['error' => 'csrf', 'reason' => $reason]);
exit;
}
function isAllowedOrigin(string $origin): bool
{
$host = parse_url($origin, PHP_URL_HOST);
return is_string($host) && hash_equals($_SERVER['HTTP_HOST'] ?? '', $host);
}
function parseBytes(string $val): int
{
$val = trim($val);
$unit = strtolower($val[strlen($val) - 1] ?? '');
$num = (int) $val;
return match ($unit) {
'g' => $num * 1024 ** 3,
'm' => $num * 1024 ** 2,
'k' => $num * 1024,
default => $num,
};
}
// ---------------------------------------------------------------------------
// Render side. The same token goes into three places on every admin page:
// the cookie, a meta tag for fetch(), and a hidden field for the no-JS forms.
// ---------------------------------------------------------------------------
$binding = hash('sha256', session_id() . '|' . $currentAdminId);
$token = $csrf->issue($binding);
$csrf->sendCookie($token);
// in the layout <head>
printf('<meta name="csrf-token" content="%s">', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'));
// in each form
printf('<input type="hidden" name="_csrf" value="%s">', htmlspecialchars($token, ENT_QUOTES, 'UTF-8'));
Do not rotate the token on every request. We tried it for two days. Editors keep six or seven tabs open on the moderation queue, and per-request rotation means the five older tabs are all holding dead tokens; the first bulk action after switching tabs fails. Rotate on login, on logout, and on privilege change. Let the TTL handle the rest.
The client side
The client needs to attach the token to every unsafe request and recover cleanly when it expires — a moderator who left a tab open over lunch should not lose a queue of selections.
let csrfToken = document.querySelector('meta[name="csrf-token"]')?.content ?? '';
async function refreshCsrf() {
const res = await fetch('/admin/csrf', { credentials: 'same-origin' });
if (!res.ok) throw new Error('csrf refresh failed');
csrfToken = (await res.json()).token;
return csrfToken;
}
export async function apiPost(url, payload, { retried = false } = {}) {
const res = await fetch(url, {
method: 'POST',
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken,
},
body: JSON.stringify(payload),
});
if (res.status === 419 && !retried) {
await refreshCsrf();
return apiPost(url, payload, { retried: true });
}
if (res.status === 403) {
const { reason } = await res.json().catch(() => ({ reason: 'unknown' }));
throw new Error(`request rejected: ${reason}`);
}
return res.json();
}
The /admin/csrf endpoint is a GET that requires an authenticated session, sets a fresh cookie, and returns the token. It must send Cache-Control: no-store — a cached token refresh endpoint would be a genuinely funny bug to debug.
Retry exactly once. An unbounded retry loop against a server that has decided your session is dead is a self-inflicted denial of service, and I have watched a colleague ship one.
Testing the attacks, not just the happy path
Unit tests that assert "valid token passes, missing token fails" are close to worthless here. The interesting cases are the forgeries. This harness runs against a throwaway instance in CI:
import time
import requests
BASE = "http://127.0.0.1:8080"
TARGET = f"{BASE}/admin/videos/bulk"
PAYLOAD = {"action": "region_block", "ids": [1, 2, 3]}
def logged_in_session():
s = requests.Session()
s.post(f"{BASE}/admin/login", data={"user": "ci", "pass": "ci-password"})
page = s.get(f"{BASE}/admin/queue").text
token = page.split('name="csrf-token" content="')[1].split('"')[0]
return s, token
def post(session, token=None, header=True, cookie=None):
headers = {"X-CSRF-Token": token} if (token and header) else {}
cookies = {"dev-csrf": cookie} if cookie else None
return session.post(TARGET, json=PAYLOAD, headers=headers, cookies=cookies)
def test_valid_request_passes():
s, token = logged_in_session()
assert post(s, token).status_code == 200
def test_json_body_without_token_is_rejected():
# The original bug: JSON body left $_POST empty and skipped the guard.
s, _ = logged_in_session()
assert post(s).status_code == 403
def test_cookie_tossing_is_rejected():
# Attacker controls both copies but cannot produce a valid MAC.
s, _ = logged_in_session()
forged = "v1.%d.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.ZZZZZZZZ" % (int(time.time()) + 3600)
r = post(s, forged, cookie=forged)
assert r.status_code == 403
assert r.json()["reason"] == "bad_signature"
def test_truncated_mac_is_rejected():
s, token = logged_in_session()
v, exp, nonce, mac = token.split(".")
clipped = ".".join([v, exp, nonce, mac[:8]])
assert post(s, clipped, cookie=clipped).json()["reason"] == "bad_signature"
def test_token_from_another_session_is_rejected():
victim, _ = logged_in_session()
attacker, stolen = logged_in_session()
r = post(victim, stolen, cookie=stolen)
assert r.status_code == 403
assert r.json()["reason"] == "bad_signature" # binding differs -> MAC differs
def test_expired_token_returns_419():
s, token = logged_in_session()
v, _, nonce, mac = token.split(".")
stale = ".".join([v, str(int(time.time()) - 99999), nonce, mac])
assert post(s, stale, cookie=stale).status_code in (403, 419)
The cross-session test is the one I would keep if I could keep only one. It is the test that fails the moment somebody "simplifies" the binding out of the signature input because it made a refactor awkward.
LiteSpeed and Cloudflare
Two infrastructure notes that cost me time.
First, admin routes must be excluded from every cache layer, and on LiteSpeed the exclusion has to be inside a LiteSpeed-specific block — Apache's mod_rewrite parses commas inside [E=...] flags as flag separators and returns a 500:
<IfModule LiteSpeed>
CacheEngine on
RewriteEngine On
RewriteRule ^admin - [E=Cache-Control:no-cache]
RewriteCond %{HTTP_COOKIE} (^|;\s*)__Host-adm=
RewriteRule .* - [E=Cache-Control:no-cache]
</IfModule>
The second rule is the belt-and-braces one: if the admin session cookie is present at all, nothing about that response is cacheable, whatever route it is.
Second, if you terminate at a Cloudflare Worker, make sure the Worker forwards the Cookie header untouched and does not apply a "Cache Everything" page rule to admin paths. A Worker that constructs a new Request and forgets to copy headers will strip your X-CSRF-Token, and the resulting 403 looks exactly like an attack in your logs.
Failure modes we hit in production
-
__Host-on local HTTP. The browser drops the cookie silently — no console warning that anyone notices. Everything 403s locally and works in staging. Hence thecookieName()switch on scheme. -
Upload over
post_max_size. PHP delivers an empty$_POSTand$_FILES, so the token vanishes and the failure reports as CSRF. Editors uploading thumbnails saw "security error" and assumed they had been logged out. TheCONTENT_LENGTHpre-check in the guard turns that into an honest 413. - Audit-log write amplification. Logging every failure straight to SQLite was fine until a misconfigured crawler hammered a POST endpoint and we took thousands of writes per minute against a WAL that also serves the trending-video ingest. Failures are now aggregated in APCu and flushed once a minute.
-
Clock skew. Two hosts drifted about 40 seconds apart and produced sporadic
expiredrejections at the boundary. The 60-second leeway plus NTP fixed it. Do not set leeway to zero to look rigorous. -
The exempt list is the real risk. Every entry in
CSRF_EXEMPTis a route where you have promised the authentication is not cookie-based. We review that list on every release, and each entry has a comment naming the mechanism that replaces the token.
Conclusion
The vulnerability that started this was not a weak algorithm. It was a guard that only ran for one of the two ways our own code called our own endpoints. That is the normal shape of a CSRF bug: not a broken primitive, a hole in the coverage.
So the design goals, in order, are coverage first and cryptography second. Check every unsafe method by default with an explicit, reviewed exemption list. Read the token from a form field, a header, or a JSON body, because your own frontend will use all three eventually. Then make the token unforgeable with an HMAC over an expiry, a nonce, and a session binding, ship it under a __Host- prefix, and verify the signature before you branch on anything the client sent. The stateless part is a bonus: no session locking, no shared server state, and a cookie that is genuinely strictly necessary and therefore trivially defensible to a regulator.
Test the forgeries, not the happy path. And write down why every exempt route is exempt — that list is where the next incident is hiding.
Top comments (0)