Last month a security researcher emailed me a proof-of-concept that quietly published a video to the front page of DailyWatch using nothing but an HTML form hosted on a throwaway domain. I was logged into the admin panel in another tab. He visited his page, my browser dutifully attached my session cookie, and his hidden form POSTed to /admin/videos/feature. No alert, no XSS, no stolen password — just a forged request riding on my already-authenticated session. That is CSRF, and if your video admin panel authorizes state-changing actions on the strength of a session cookie alone, you are exposed in exactly the same way.
This post walks through how we hardened the DailyWatch admin panel with the double-submit cookie pattern on PHP 8.4. It is the technique I reach for when a full server-side token store is more bookkeeping than the threat justifies, and it sits cleanly behind LiteSpeed and Cloudflare without forcing me to disable page caching everywhere. I will show the naive version, the signed HMAC version that closes a real subdomain hole, and the operational details that actually bit us in production.
Why I Skipped the Synchronizer Token Pattern
The textbook defense is the synchronizer token: generate a random value, stash it in the server-side session, embed it in every form, and compare the submitted value against the stored one on each request. It works, and for a stateful Rails-style app it is the right default.
It fit our admin panel badly for two concrete reasons.
First, caching. A meaningful chunk of DailyWatch is served straight from LiteSpeed's page cache, and the admin shell shares templates and partials with the public site. A synchronizer token is per-session state baked into HTML — the moment a token-bearing page lands in any shared cache, the token is either stale or shared across users. You end up carving cache exclusions around every fragment that touches the token. Double-submit needs zero server-side state: the cookie is the source of truth, and it travels with the user rather than with the cached HTML.
Second, multiple tabs. Editors at DailyWatch routinely keep three or four admin tabs open — one curating the homepage, one moderating comments, one editing channel metadata. Per-request synchronizer tokens that rotate aggressively turn that workflow into a parade of 403s. A cookie-scoped token with a sane TTL is stable across tabs by construction.
Double-submit trades a tiny amount of cryptographic care (which I will get to) for being effectively stateless. That trade is the whole point.
The Pattern in One Paragraph
The server sets a high-entropy random value in a cookie. Every state-changing request must echo that same value back — either in a request-body field or, for JSON APIs, in a custom request header. The server checks that the cookie value and the echoed value match. The security rests on the same-origin policy: an attacker's cross-site page can make the browser send your cookie along with a forged request, but it cannot read that cookie to copy the value into the body or a custom header. No read, no match, no forged action. That asymmetry is the entire defense.
Issuing the Token
Here is the minimal issuer. I generate 32 bytes of CSPRNG output, hex-encode it, and drop it in a cookie that JavaScript is allowed to read.
<?php
declare(strict_types=1);
final class Csrf
{
private const COOKIE = 'dw_csrf';
private const FIELD = 'csrf_token';
private const TTL = 7200; // 2 hours
public static function issue(): string
{
$token = bin2hex(random_bytes(32));
setcookie(self::COOKIE, $token, [
'expires' => time() + self::TTL,
'path' => '/',
'secure' => true, // HTTPS only — non-negotiable
'httponly' => false, // the front-end must read it to echo it back
'samesite' => 'Lax',
]);
return $token;
}
}
Two decisions deserve a comment because they look wrong at first glance.
httponly is false on purpose. The whole mechanism depends on our own JavaScript reading the cookie to copy it into a header. That feels like it weakens things, but it does not: CSRF is not the same threat as XSS. If an attacker can run JavaScript on your origin (XSS), CSRF defenses are already moot — they can read any token regardless of the cookie flags. The httponly flag protects against script theft, which is an XSS concern, not a CSRF one.
secure is true and samesite is Lax. SameSite=Lax already blocks the most common cross-site POST, so think of double-submit as defense in depth layered on top of it, not a replacement. I will come back to why I do not rely on SameSite alone.
Call Csrf::issue() once per page render of the admin shell. If a valid dw_csrf cookie already exists you can skip re-issuing to keep multiple tabs in sync, or refresh the TTL — both are fine.
Validating Every State-Changing Request
Validation reads the cookie, reads whatever the client echoed back, and compares them in constant time.
public static function validate(): bool
{
$cookie = $_COOKIE[self::COOKIE] ?? '';
$sent = $_POST[self::FIELD]
?? $_SERVER['HTTP_X_CSRF_TOKEN']
?? '';
if ($cookie === '' || $sent === '') {
return false;
}
// Constant-time compare. Never use == or === on a secret —
// string comparison short-circuits and leaks length/timing.
return hash_equals($cookie, $sent);
}
The hash_equals call matters more than it looks. A plain === returns as soon as it hits the first differing byte, which leaks timing information an attacker can in principle exploit to recover the value byte by byte. hash_equals compares the full length regardless. It is one function call; use it for every secret comparison you ever write.
Wire validation into the front controller so no route can forget it. The rule is simple: idempotent methods (GET, HEAD, OPTIONS) are exempt because they must not change state; everything else is checked.
$method = $_SERVER['REQUEST_METHOD'];
if (in_array($method, ['POST', 'PUT', 'PATCH', 'DELETE'], true)) {
if (!Csrf::validate()) {
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'CSRF token mismatch']);
exit;
}
}
Putting this in the dispatcher rather than in each controller is the difference between a defense that holds and one that holds until someone adds a new endpoint and forgets the guard. The default must be secure; opting out (for a deliberately public webhook, say) must be the explicit, visible choice.
Wiring the Front-End
For classic form posts, render a hidden input with the token. For our JSON admin API, I prefer a custom header — it keeps the token out of request bodies and logs, and X-CSRF-Token is a request the browser will only let same-origin script set.
function csrfToken() {
return document.cookie
.split('; ')
.find((c) => c.startsWith('dw_csrf='))
?.split('=')[1] ?? '';
}
async function adminPost(url, payload) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify(payload),
});
if (res.status === 403) {
throw new Error('CSRF rejected — reload the admin page to refresh the token');
}
return res.json();
}
There is a subtle reason the custom-header approach is strong on its own: a cross-origin fetch that sets a non-standard header like X-CSRF-Token triggers a CORS preflight, and absent a permissive CORS policy on your origin (you do not have one on an admin panel, right?) the browser blocks it before the real request ever fires. So for JSON endpoints the custom header is doing double duty. I still validate the double-submit match because forms and older flows fall back to the body field, and I want one uniform check.
The Hole in Plain Double-Submit, and the Signed Fix
Here is the part most tutorials skip, and it is the part that actually matters for a platform with subdomains.
Plain double-submit assumes the attacker cannot write your CSRF cookie. That assumption breaks if they control any sibling or subdomain that can set a cookie on your registrable domain — a technique called cookie tossing. If an attacker can get the browser to store a dw_csrf cookie scoped to .dailywatch.video with a value they chose, they can then forge a request whose body carries that same known value. Cookie and body match, validation passes, defense defeated. Shared hosting, a compromised marketing subdomain, or a wildcard you forgot about can all open this door.
The fix is to make the token unforgeable by binding it to the session with an HMAC. An injected cookie now fails the signature check because the attacker does not have the server secret.
final class SignedCsrf
{
private const COOKIE = '__Host-dw_csrf';
private const TTL = 7200;
public function __construct(private readonly string $secret) {}
public function issue(string $sessionId): string
{
$random = bin2hex(random_bytes(16));
$expires = time() + self::TTL;
$message = "{$sessionId}.{$random}.{$expires}";
$sig = hash_hmac('sha256', $message, $this->secret);
$token = "{$random}.{$expires}.{$sig}";
setcookie(self::COOKIE, $token, [
'expires' => $expires,
'path' => '/', // required for the __Host- prefix
'secure' => true, // required for the __Host- prefix
'httponly' => false,
'samesite' => 'Lax',
// note: NO 'domain' key — __Host- forbids it
]);
return $token;
}
public function validate(string $sessionId, string $sent): bool
{
$cookie = $_COOKIE[self::COOKIE] ?? '';
if ($cookie === '' || !hash_equals($cookie, $sent)) {
return false;
}
[$random, $expires, $sig] = array_pad(explode('.', $sent), 3, '');
if ((int) $expires < time()) {
return false; // expired token
}
$expected = hash_hmac(
'sha256',
"{$sessionId}.{$random}.{$expires}",
$this->secret
);
return hash_equals($expected, $sig);
}
}
Two defenses are stacked here, and that is deliberate.
The HMAC binds the token to the current session id and an expiry, signed with a secret only the server knows. A tossed cookie carrying an attacker-chosen value will not carry a valid signature for my session, so validate rejects it. The expiry baked into the signed payload also gives you real, tamper-proof token lifetime rather than relying on the cookie's Max-Age, which the client controls.
The __Host- cookie name prefix is a browser-enforced belt to the HMAC's suspenders. A cookie named with the __Host- prefix is only accepted by the browser if it is Secure, has Path=/, and has no Domain attribute — which means it cannot be set from a different host and cannot be scoped to the parent domain. That structurally blocks subdomain cookie tossing at the source. Keep the secret out of your repo; on DailyWatch it lives in the environment, not in code.
SameSite, LiteSpeed, and Cloudflare Gotchas
The cryptography is the easy half. The operational edges are where I lost an afternoon.
-
Do not cache admin responses. LiteSpeed's
CacheEnable public /is a blessing for the public site and a footgun for the admin panel. If a page that carries the token gets cached, you can pin every admin to one token, or worse cache aSet-Cookie. Exclude the admin path explicitly and sendCache-Control: no-store, privateon every admin response. Double-submit still functions if the same token is shared (it only needs cookie and body to match), but cachingSet-Cookieis a genuine cross-user leak. -
Watch Cloudflare's Cache Everything. If you enable a Cache Everything page rule, make sure it bypasses on cookie and never applies to the admin path. Custom headers like
X-CSRF-Tokenpass through Cloudflare untouched, so the header-based flow is safe across the proxy; cached HTML carrying a token is not. - SameSite=Lax is layered defense, not the whole wall. Lax blocks cross-site POSTs but still permits top-level cross-site GET navigations, and browser support and defaults vary across the long tail of clients you actually see in logs. Treat it as one layer. The double-submit check is what you can reason about deterministically.
- Rotate on privilege change. Issue a fresh token on login and clear the cookie on logout. Binding the HMAC to the session id already invalidates old tokens when the session rotates, but clearing the cookie keeps things tidy and avoids confusing 403s after a re-login.
-
Mind clock skew on expiry. The signed token's expiry is checked server-side, so a single server clock governs it — no skew across a fleet to worry about, unlike client-side
Max-Age.
Testing It
I do not trust a security control I have not watched fail and pass. A two-line curl harness covers the two cases that matter, and it drops straight into CI against a staging build.
# 1. Forged request with NO token must be rejected.
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://staging.dailywatch.video/admin/videos/feature \
-b "session=valid-session-id" \
-d "video_id=42"
# expected: 403
# 2. Legit request: echo the cookie token back in the header.
TOKEN=$(curl -s -c - https://staging.dailywatch.video/admin \
| awk '/__Host-dw_csrf/ {print $7}')
curl -s -o /dev/null -w "%{http_code}\n" \
-X POST https://staging.dailywatch.video/admin/videos/feature \
-b "session=valid-session-id; __Host-dw_csrf=$TOKEN" \
-H "X-CSRF-Token: $TOKEN" \
-d "video_id=42"
# expected: 200
The first request proves the guard rejects the forged-form scenario that started this whole post. The second proves a legitimate same-origin flow still works. Add a third case that sends a mismatched token (cookie X, header Y) and assert 403 — that is the case a regression is most likely to silently break. Wire these into CI so a future refactor that drops the dispatcher guard fails the build instead of failing a user.
Conclusion
CSRF is unglamorous and easy to under-rate right up until someone publishes to your homepage from a domain you have never heard of. The double-submit cookie pattern gave us a defense that is stateless enough to coexist with aggressive LiteSpeed and Cloudflare caching, stable enough for editors juggling multiple tabs, and — in its signed form — robust against the subdomain cookie-tossing attack that plain double-submit quietly ignores.
If you take three things from this:
- Validate in the dispatcher, not per controller, so secure is the default and opting out is explicit.
- Always compare secrets with
hash_equals, never===. - If you have subdomains, use the signed HMAC variant with a
__Host-cookie prefix — plain double-submit assumes an attacker cannot write your cookie, and that assumption is often false.
It is maybe a hundred lines of PHP. The researcher who reported our hole now gets a 403 for his trouble, which is exactly the result I was after.
Top comments (0)