Last month I watched a bulk-delete endpoint on our video admin panel get hit by a forged POST during a routine log review. Nothing was lost — the request failed a token check — but the log line was enough to make me audit every state-changing route we ship. The admin panel behind DailyWatch controls video ingestion, category curation, and cache invalidation across a LiteSpeed + Cloudflare edge, and a single successful cross-site request could flush the wrong cache or unpublish a channel. This post is the exact CSRF defense we run in production: the double-submit cookie pattern, implemented in PHP 8.4, hardened against the mistakes that make most double-submit implementations useless.
Why Double-Submit and Not Synchronizer Tokens
The classic CSRF defense is the synchronizer token: generate a random token, store it in the server-side session, embed it in every form, and compare on submit. It works, but it has a cost that matters for a video platform serving cached pages at the edge.
Our admin panel runs on SQLite with FTS5 for search and sits behind a page cache. Session-bound tokens mean every admin page render must touch session storage, and every token is tied to one server's session state. That's fine for a single box, but it fights against the stateless request model we want everywhere else on the site.
The double-submit cookie pattern removes the server-side storage requirement:
- The server sends a random token in a cookie.
- The same token is embedded in the form (or a request header).
- On submit, the server checks that the cookie value and the submitted value match.
The security assumption is simple: an attacker on evil.com can trigger a request to your domain and the browser will attach your cookies automatically — that's the whole basis of CSRF — but the attacker cannot read your cookie value because of the Same-Origin Policy, and therefore cannot place the matching value in the request body. If cookie and body match, the request came from a page that could read the cookie, which means it came from your own origin.
That assumption has holes. Most tutorials stop before covering them, which is why so many double-submit implementations are quietly broken. We'll get to the holes.
Generating and Setting the Token
First, the token itself. Never use rand(), mt_rand(), uniqid(), or anything derived from timestamps. CSRF tokens must be cryptographically random and long enough that guessing is infeasible. In PHP that means random_bytes().
Here is the token manager we use. It is a small class with no session dependency — state lives entirely in the cookie.
<?php
declare(strict_types=1);
final class CsrfToken
{
private const COOKIE_NAME = '__Host-csrf';
private const FIELD_NAME = 'csrf_token';
private const TOKEN_BYTES = 32;
public function issue(): string
{
$existing = $_COOKIE[self::COOKIE_NAME] ?? '';
if ($this->isWellFormed($existing)) {
// Reuse the token for the lifetime of the browser session
// so multiple tabs share one value.
return $existing;
}
$token = bin2hex(random_bytes(self::TOKEN_BYTES));
$this->send($token);
return $token;
}
private function send(string $token): void
{
setcookie(self::COOKIE_NAME, $token, [
'expires' => 0, // session cookie
'path' => '/',
'secure' => true, // required by __Host- prefix
'httponly' => false, // JS must read it for the header variant
'samesite' => 'Lax',
]);
}
private function isWellFormed(string $value): bool
{
// 32 bytes hex-encoded = 64 chars, hex only.
return (bool) preg_match('/^[a-f0-9]{64}$/', $value);
}
public function fieldName(): string
{
return self::FIELD_NAME;
}
public function cookieName(): string
{
return self::COOKIE_NAME;
}
}
A few decisions worth calling out:
-
The
__Host-cookie prefix is doing real work. A cookie named__Host-csrfis only accepted by the browser if it is set withSecure, hasPath=/, and has noDomainattribute. That last part is the important one. Without it, a subdomain likeblog.dailywatch.video— or worse, an attacker who manages to get code onto any subdomain — could set aDomain=dailywatch.videocookie that overwrites yours. The__Host-prefix makes subdomain cookie-injection impossible. This closes one of the biggest double-submit holes. -
httponly => falselooks wrong for a security cookie, and it is a real tradeoff. We keep it readable because our admin panel is a single-page-ish interface that sends the token in a request header viafetch. If your panel is pure server-rendered forms, sethttponly => trueand embed the token in a hidden field instead — the cookie never needs to be JS-readable in that case. Choose based on your panel, and I'll show both submit paths below. -
Session-lifetime cookie (
expires => 0) means the token rotates when the browser closes. For an admin panel that's a reasonable balance; a full rotate-per-request scheme breaks multi-tab workflows and back-button navigation without adding meaningful security to double-submit.
Embedding the Token in Forms
For server-rendered forms, the token goes in a hidden input on every state-changing form. Here's the render helper and a sample form from our category management page.
<?php
// In the admin bootstrap, once per request:
$csrf = new CsrfToken();
$token = $csrf->issue();
function csrf_field(CsrfToken $csrf, string $token): string
{
return sprintf(
'<input type="hidden" name="%s" value="%s">',
htmlspecialchars($csrf->fieldName(), ENT_QUOTES, 'UTF-8'),
htmlspecialchars($token, ENT_QUOTES, 'UTF-8')
);
}
?>
<form method="post" action="/ibt/category/delete">
<?= csrf_field($csrf, $token) ?>
<input type="hidden" name="category_id" value="42">
<button type="submit">Delete category</button>
</form>
Every POST, PUT, PATCH, and DELETE form in the panel gets csrf_field(). The rule I enforce in review is blunt: if a form mutates state and doesn't call csrf_field(), it doesn't merge. GET requests never mutate state, so they never carry a token — that's not laziness, it's correctness. A CSRF-protected GET is a sign your GET is doing something it shouldn't.
Validating on the Server
Now the check. This runs before any state-changing route executes. The comparison must be constant-time to avoid leaking token bytes through timing, and it must reject empty or malformed values instead of treating two empty strings as a match — the second most common double-submit bug after the subdomain hole.
<?php
declare(strict_types=1);
final class CsrfValidator
{
private const HEADER_NAME = 'HTTP_X_CSRF_TOKEN';
public function __construct(private CsrfToken $csrf) {}
public function assertValid(): void
{
if (!$this->isMutating($_SERVER['REQUEST_METHOD'] ?? 'GET')) {
return; // GET/HEAD/OPTIONS are never checked
}
$cookie = $_COOKIE[$this->csrf->cookieName()] ?? '';
$sent = $this->submittedToken();
// Reject anything that isn't a well-formed 64-char hex token.
// This is what stops '' === '' from ever passing.
if (!preg_match('/^[a-f0-9]{64}$/', $cookie)
|| !preg_match('/^[a-f0-9]{64}$/', $sent)) {
$this->reject('malformed or missing token');
}
if (!hash_equals($cookie, $sent)) {
$this->reject('token mismatch');
}
}
private function submittedToken(): string
{
// Header takes precedence (fetch/XHR), then form field.
$header = $_SERVER[self::HEADER_NAME] ?? '';
if ($header !== '') {
return $header;
}
return (string) ($_POST[$this->csrf->fieldName()] ?? '');
}
private function isMutating(string $method): bool
{
return in_array(
strtoupper($method),
['POST', 'PUT', 'PATCH', 'DELETE'],
true
);
}
private function reject(string $reason): never
{
error_log('CSRF rejected: ' . $reason . ' ip=' . ($_SERVER['REMOTE_ADDR'] ?? '?'));
http_response_code(403);
header('Content-Type: text/plain; charset=utf-8');
echo 'Forbidden: CSRF validation failed.';
exit;
}
}
The three defenses stacked here are what separate a real implementation from a demo:
-
hash_equals()does a constant-time comparison. Using===or==here leaks how many leading bytes matched via response timing, which over enough requests lets an attacker reconstruct the token. Never compare secrets with==. -
The regex gate before the compare rejects empty strings,
null-coerced values, and anything that isn't a proper token. Without it, a request with no cookie and no field would compare'' === ''and pass. That single missing check has appeared in production code at companies you've heard of. - Method filtering means we only spend the check on mutating requests, and it documents the invariant that GETs are side-effect-free.
Wiring it into the router is one line at the top of the admin dispatch:
<?php
$csrf = new CsrfToken();
$csrf->issue(); // ensures the cookie exists on every admin page load
(new CsrfValidator($csrf))->assertValid();
// ... only reached if the request is a safe method or passes CSRF
$router->dispatch($_SERVER['REQUEST_METHOD'], $path);
The Cloudflare and LiteSpeed Gotcha
This is the part that bites people running behind a CDN, and it's specific to our stack. The Set-Cookie header for the CSRF token must never be cached at the edge, or every admin behind Cloudflare gets served the same token — which turns your per-user secret into a shared, publicly-known value and defeats the entire scheme.
Two things protect us:
- The admin panel path (
/ibton our sites) is excluded from LiteSpeed page cache and markedno-store, privateat the origin. Cloudflare's default behavior already bypasses caching when it seesSet-Cookie, but I never rely on defaults for a security control — the admin routes send explicit cache-control headers. - We set the cookie with
Secure, which combined with Cloudflare Flexible SSL means the browser only sends it over HTTPS. One subtlety with Flexible mode: the origin sees HTTP from Cloudflare, so PHP's$_SERVER['HTTPS']may be unset. TheSecureflag onsetcookie()is set unconditionally in our code rather than derived from$_SERVER['HTTPS'], because the browser-facing connection is always HTTPS even when the origin leg isn't.
If you cache your admin pages, verify with curl -I that no admin response carries a cacheable Set-Cookie. I've seen a misconfigured cache rule hand the same CSRF token to an entire office.
Handling the Header Variant for fetch()
Our category and channel editors use fetch() for inline saves without full page reloads. Those requests read the cookie in JavaScript and echo it back in an X-CSRF-Token header. Because the cookie is same-origin, only our own scripts can read it — cross-origin JS cannot.
function csrfToken() {
const match = document.cookie.match(/(?:^|;\s*)__Host-csrf=([a-f0-9]{64})/);
return match ? match[1] : '';
}
async function saveCategory(id, payload) {
const res = await fetch('/ibt/category/update', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfToken(),
},
credentials: 'same-origin',
body: JSON.stringify({ id, ...payload }),
});
if (res.status === 403) {
// Token rotated (browser reopened) — reload to get a fresh cookie.
window.location.reload();
return;
}
return res.json();
}
The header path has a genuine security bonus over the form-field path: custom headers like X-CSRF-Token cannot be set on a cross-origin request without triggering a CORS preflight, and our origin doesn't approve preflights from other domains. So even before the token comparison runs, the mere presence of a readable custom header on a same-origin request is itself a CSRF signal. That's why we prefer the header variant where the UI allows it.
Defending the Holes People Skip
Double-submit gets dismissed by some engineers as weaker than synchronizer tokens. It is weaker only if you skip the hardening. Here's the full list of what actually makes it safe, and what we do for each:
-
Subdomain cookie injection. An attacker who controls or injects into any subdomain can normally set a domain-wide cookie and overwrite your CSRF cookie with a known value, then submit that same known value. Fixed by the
__Host-prefix, which forbids theDomainattribute entirely. - Empty-string comparison. Covered above — the regex gate rejects missing tokens before the compare.
-
Timing attacks. Covered by
hash_equals(). -
Method confusion. Some frameworks let a POST be tunneled as a GET with a
_methodoverride, or accept the token in a query string. We only read the token from the header or$_POSTbody, never from$_GET, and we only validate real mutating methods. -
SameSite as defense-in-depth, not the primary control. We set
SameSite=Lax, which blocks the cookie from being sent on most cross-site POSTs in modern browsers. That's a strong second layer, but SameSite handling varies across browser versions and doesn't cover every navigation type, so it backstops the token check rather than replacing it. - Login CSRF. The login form itself gets a token too. An attacker forcing a victim to log into an attacker-controlled account is a real class of CSRF, and an unprotected login form is a common blind spot.
One thing double-submit does not protect against is a full compromise of your origin via XSS. If an attacker can run JavaScript on your domain, they can read the cookie and forge any request. CSRF tokens and XSS defenses are orthogonal — you need both. On the admin panel we pair this with a strict Content-Security-Policy and output encoding on every rendered value, because a token is worthless if evil.com's script is running on your page.
Testing It
A CSRF control you haven't tried to break is a control you don't trust. The checks I run before shipping any change to the admin auth path:
- A POST with no cookie and no token returns 403.
- A POST with a valid cookie but no submitted token returns 403.
- A POST with mismatched cookie and token returns 403.
- A POST with matching cookie and token succeeds.
- A GET to a mutating route is rejected by the router regardless of token (mutating routes shouldn't answer GET at all).
-
curl -Ion every admin route shows no cacheableSet-Cookie.
A quick shell harness against a staging box catches regressions:
# Grab a fresh token cookie, then replay it with a matching field.
TOKEN=$(curl -sic - https://staging.example/ibt \
| grep -oP '__Host-csrf=\K[a-f0-9]{64}')
# Should succeed (200):
curl -s -o /dev/null -w '%{http_code}\n' \
-b "__Host-csrf=$TOKEN" \
-H "X-CSRF-Token: $TOKEN" \
-X POST https://staging.example/ibt/category/noop
# Should fail (403) — cookie present, token absent:
curl -s -o /dev/null -w '%{http_code}\n' \
-b "__Host-csrf=$TOKEN" \
-X POST https://staging.example/ibt/category/noop
Conclusion
Double-submit cookies get a bad reputation because most implementations copy the happy path and skip the hardening — and an unhardened double-submit really is broken. The version we run is only a few dozen lines, but every line closes a specific hole: random_bytes() for unguessable tokens, the __Host- prefix against subdomain injection, a strict regex to kill empty-string matches, hash_equals() against timing leaks, method filtering to keep GETs pure, and explicit no-cache headers so the CDN never leaks a shared token. Together they give us stateless CSRF protection that doesn't fight our cache architecture, which is exactly why it's the pattern we chose for the panel behind DailyWatch. If you're protecting a PHP admin surface behind Cloudflare and LiteSpeed, this is the whole thing — copy it, then run the failing tests yourself before you trust it.
Top comments (0)