A forged POST that purged our entire CDN cache
Last spring I watched the LiteSpeed page-cache hit ratio on DailyWatch fall off a cliff for twenty minutes. Nothing had deployed. No cron had fired. What actually happened was dumber and scarier than any of that: a bookmarked browser tab, still logged into our admin panel, loaded an unrelated forum page, and that page auto-submitted a hidden HTML form to /ibt/purge-cache. The browser dutifully attached our session cookie, the origin saw a perfectly authenticated request, and the entire edge cache evaporated. Rebuild traffic hammered SQLite for the next half hour.
That is Cross-Site Request Forgery. If your PHP video admin panel authenticates state-changing actions — approve a video, rewrite metadata, purge cache, trigger a re-fetch — using nothing but a session cookie, you have exactly the same hole. The browser sends your cookie on any request to your origin, including requests initiated by a page you don't control. Authentication is not authorization of intent.
This post walks through the pattern I settled on: signed double-submit cookies. It's stateless, it plays nicely with an aggressive page cache, and it fits a PHP 8.4 codebase without dragging in a framework.
Why not server-stored synchronizer tokens
The textbook answer to CSRF is the synchronizer token pattern: generate a random token, store it in the server-side session, embed a copy in every form, compare on submit. It works. But on a video discovery platform it fights the rest of the architecture:
- Session storage becomes a write bottleneck. Our admin actions are bursty. Storing and rotating per-request tokens in the SQLite-backed session means write locks on the hot path, competing with the fetch cron.
- Page caching gets awkward. We serve most pages through a LiteSpeed page cache and a PHP file cache. A token that must be unique per session cannot live in a cached HTML body without poisoning the cache for everyone.
- Horizontal moves get harder. Even though we run single-origin per domain today, anything that assumes sticky server-side session state is a future migration tax.
The double-submit cookie pattern sidesteps all of that. The token lives in a cookie and in the request (header or form field). The server compares the two without storing anything. Same-origin policy guarantees an attacker's page can neither read nor set our cookie, so it cannot make the two copies match.
The naive version has a known weakness: a related subdomain (or a network attacker on plain HTTP) can plant a cookie the victim's browser will send, letting the attacker control both halves. So I use the signed variant OWASP recommends — the token carries an HMAC the attacker can't forge — plus the __Host- cookie prefix to lock down scope.
How the signed double-submit flow works
The lifecycle is small enough to hold in your head:
- On any authenticated admin GET, issue a token:
random . expiry, signed with a server secret, written to a__Host-cookie. - Render the same token into a hidden form field, and expose it to JavaScript for
fetchcalls. - On every unsafe request (POST/PUT/PATCH/DELETE), read the cookie and the submitted copy. Require they are byte-for-byte equal, the signature verifies, and the expiry hasn't passed.
- Reject with
403on any failure.
Because the secret never leaves the server, an attacker who can't read our cookie can't produce a value whose signature checks out and whose plaintext matches the cookie. Both conditions have to hold.
Issuing the token
Here is the issuer. Note the __Host- prefix requirements: the cookie must be Secure, have Path=/, and carry no Domain attribute. The browser refuses to accept a __Host--prefixed cookie that violates those rules, which is precisely why it's useful — a subdomain physically cannot overwrite it.
<?php
declare(strict_types=1);
final class CsrfToken
{
private const COOKIE = '__Host-csrf';
private const TTL = 7200; // 2 hours
public function __construct(private readonly string $secret) {}
/** Issue a fresh signed token and set the cookie. Returns the token. */
public function issue(): string
{
$random = bin2hex(random_bytes(32));
$expires = time() + self::TTL;
$payload = $random . '.' . $expires;
$sig = hash_hmac('sha256', $payload, $this->secret);
$token = $payload . '.' . $sig;
setcookie(self::COOKIE, $token, [
'expires' => $expires,
'path' => '/',
'secure' => true, // required by __Host- prefix
'httponly' => false, // JS reads it for the header variant
'samesite' => 'Lax', // Strict breaks OAuth-style redirects
]);
return $token;
}
}
Two choices deserve a note. First, httponly is false. That feels wrong until you remember what double-submit actually defends: it does not rely on the token being secret from same-origin JavaScript. It relies on a cross-origin page being unable to read it. Letting our own scripts read the cookie is fine, and it's what makes the fetch/header path work. Second, SameSite=Lax is deliberate. Strict would block the cookie on top-level cross-site navigations, which breaks legitimate flows like clicking an admin link from an email. Lax still blocks the dangerous case — cross-site POSTs — because Lax cookies are withheld from cross-origin form submissions.
Call issue() exactly once per rendered admin page, before any output, and only for authenticated admins. Do not call it on cached pages.
Verifying on unsafe requests
The guard runs on every request that could change state. Safe methods pass through untouched; the CSRF model only concerns side-effecting verbs.
<?php
declare(strict_types=1);
final class CsrfGuard
{
private const COOKIE = '__Host-csrf';
private const FIELD = '_csrf';
public function __construct(private readonly string $secret) {}
public function verify(): void
{
$method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
return; // safe, idempotent methods carry no CSRF risk
}
$cookie = (string) ($_COOKIE[self::COOKIE] ?? '');
$sent = (string) ($_SERVER['HTTP_X_CSRF_TOKEN']
?? $_POST[self::FIELD] ?? '');
if ($cookie === '' || $sent === '') {
$this->reject('missing token');
}
// Constant-time equality: the two submitted copies must match.
if (!hash_equals($cookie, $sent)) {
$this->reject('token mismatch');
}
// And the cookie itself must be one we signed, unexpired.
if (!$this->signatureValid($cookie)) {
$this->reject('bad signature');
}
}
private function signatureValid(string $token): bool
{
$parts = explode('.', $token);
if (count($parts) !== 3) {
return false;
}
[$random, $expires, $sig] = $parts;
if (!ctype_digit($expires) || (int) $expires < time()) {
return false; // expired or malformed
}
$expected = hash_hmac('sha256', $random . '.' . $expires, $this->secret);
return hash_equals($expected, $sig);
}
private function reject(string $reason): never
{
error_log('CSRF reject: ' . $reason . ' ip=' . ($_SERVER['HTTP_CF_CONNECTING_IP'] ?? '?'));
http_response_code(403);
header('Content-Type: application/json');
echo json_encode(['error' => 'csrf_failed']);
exit;
}
}
The verification does three independent things, and all three matter:
-
hash_equals($cookie, $sent)enforces the double-submit invariant — a cross-origin attacker can't read the cookie, so they can't make the submitted copy equal it. -
signatureValid()upgrades this to the signed variant. Even if a same-site subdomain or a MITM on the HTTP hop plants a cookie, it won't carry a valid HMAC, so it fails. -
hash_equalseverywhere keeps comparisons constant-time. Using===on a MAC comparison leaks timing and is a real, exploited class of bug.
Wire it into your front controller so it runs before any route handler for admin paths:
$guard = new CsrfGuard(getenv('CSRF_SECRET'));
if (str_starts_with($path, '/ibt')) {
$guard->verify();
}
Keep CSRF_SECRET in your env config, not in the codebase, and make it at least 32 random bytes. Rotating it invalidates all outstanding tokens, which is a feature during an incident.
Wiring it into forms and fetch calls
Server-rendered forms get a hidden field. Always escape it — the token is hex plus a signature, but escaping on output is a habit you never want to break in a template.
function csrf_field(string $token): string
{
$safe = htmlspecialchars($token, ENT_QUOTES, 'UTF-8');
return '<input type="hidden" name="_csrf" value="' . $safe . '">';
}
Used in a template like this:
<form method="post" action="/ibt/purge-cache">
<?= csrf_field($csrfToken) ?>
<button type="submit">Purge edge cache</button>
</form>
For the AJAX side of the admin panel — the bits that approve videos or edit metadata inline — read the cookie and send it as a header. Because the cookie is same-origin, this JavaScript works; an attacker's page running on another origin cannot read our document.cookie.
function csrfHeader() {
const m = document.cookie.match(/(?:^|;\s*)__Host-csrf=([^;]+)/);
return m ? decodeURIComponent(m[1]) : '';
}
async function adminAction(url, payload) {
const res = await fetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': csrfHeader(),
},
body: JSON.stringify(payload),
credentials: 'same-origin',
});
if (res.status === 403) {
throw new Error('CSRF rejected — reload the page to refresh your token');
}
return res.json();
}
One subtlety worth internalizing: the header path is strictly stronger than the form-field path. Custom headers like X-CSRF-Token cannot be set by a cross-origin HTML form at all, and a cross-origin fetch that tries to add them triggers a CORS preflight your server never approves. So for JSON APIs you effectively get a second, independent CSRF defense for free. I still verify the token, but it's belt and suspenders.
The Cloudflare and LiteSpeed gotchas
This is where a textbook implementation meets a real edge stack, and where I lost the most time.
Never cache the token cookie. Our public pages flow through LiteSpeed page cache and, in front of that, Cloudflare. If a Set-Cookie: __Host-csrf=... header ever gets stored in a shared cache, every visitor receives the same token, and double-submit collapses — everyone's cookie matches everyone else's field. The fix is to issue tokens only on admin routes and to make sure those routes bypass the cache entirely:
- In LiteSpeed, keep the admin path out of
CacheEnableand sendCache-Control: no-store, privatefrom the admin controller. - In Cloudflare, add a cache rule that bypasses caching for the
/ibt*path prefix so the origin'sSet-Cookienever lands in the edge.
Mind Flexible SSL. We run Cloudflare in Flexible mode, so the browser-to-Cloudflare hop is HTTPS but the Cloudflare-to-origin hop is plain HTTP. The origin's PHP sees an HTTP request. That matters because __Host- and the Secure attribute are enforced by the browser on receipt — and the browser receives the response over HTTPS from Cloudflare, so the cookie is accepted. It works, but don't let setcookie be gated behind a naive isset($_SERVER['HTTPS']) check; on our origin that variable is empty. Trust X-Forwarded-Proto instead, and always send secure => true.
SameSite is not a substitute. SameSite=Lax blocks the classic cross-site form POST, and modern browsers default unlabeled cookies to Lax anyway. It is a good defense-in-depth layer. It is not a replacement for tokens: it doesn't cover same-site subdomains, older browsers, or certain GET-based state changes, and it silently varies by browser. Tokens are the control; SameSite is the seatbelt.
Proving it actually rejects forgeries
A CSRF control you haven't tried to break is a hope, not a defense. I keep a tiny harness that exercises the three failure modes plus the happy path. It talks to the guard directly by populating superglobals, so it runs anywhere without a browser.
<?php
declare(strict_types=1);
require 'CsrfToken.php';
require 'CsrfGuard.php';
$secret = 'test-secret-not-for-prod';
$issuer = new CsrfToken($secret);
// Simulate issuing a token, capturing what the cookie would hold.
$random = bin2hex(random_bytes(32));
$expires = time() + 7200;
$payload = $random . '.' . $expires;
$token = $payload . '.' . hash_hmac('sha256', $payload, $secret);
function runGuard(string $secret, string $cookie, string $sent): int {
$_SERVER['REQUEST_METHOD'] = 'POST';
$_COOKIE['__Host-csrf'] = $cookie;
$_POST['_csrf'] = $sent;
unset($_SERVER['HTTP_X_CSRF_TOKEN']);
$pid = pcntl_fork();
if ($pid === 0) { // child: guard calls exit()
(new CsrfGuard($secret))->verify();
exit(0); // reached only when it passes
}
pcntl_waitpid($pid, $status);
return http_response_code() ?: (pcntl_wexitstatus($status) === 0 ? 200 : 403);
}
assert(runGuard($secret, $token, $token) === 200); // valid: passes
assert(runGuard($secret, $token, 'garbage') !== 200); // mismatch: rejected
assert(runGuard($secret, '', $token) !== 200); // no cookie: rejected
assert(runGuard($secret, $token . 'x', $token . 'x') !== 200); // forged sig
echo "all csrf cases passed\n";
The fourth case is the important one, and it's the case naive double-submit fails: an attacker who controls both the cookie and the field but not the secret. Because the signature no longer verifies, the guard rejects it. If you ever drop the HMAC to "simplify," that assertion turns red — which is exactly why it lives in the test.
Hardening and edge cases I hit in production
-
Token expiry and long sessions. A two-hour TTL means an admin who leaves a form open overnight gets a
403on submit. I catch that on the client and reload rather than silently losing their edit. Re-issuing on every GET keeps active sessions fresh. - Multiple tabs. Every GET re-issues the cookie, so the newest tab's token wins and older tabs may go stale. If that bites you, widen the TTL or issue per-session-stable tokens (drop the per-request random). I preferred short-lived tokens.
- Login and logout. Rotate the token on privilege changes. A token minted before login shouldn't authorize actions after it.
-
Don't protect GET with side effects — remove the side effects instead. If
/ibt/delete?id=5mutates state on GET, no CSRF token saves you cleanly, because browsers pre-fetch and cache GETs. Make destructive actions POST/DELETE first, then guard them. -
Log rejections with the real client IP. Behind Cloudflare,
REMOTE_ADDRis an edge node. UseCF-Connecting-IPso your logs point at the actual origin of a forgery attempt.
Conclusion
The cache-purge incident cost us twenty minutes of degraded performance and an afternoon of forensics, and the root cause was embarrassingly ordinary: we trusted a session cookie to prove intent when all it proved was identity. Signed double-submit cookies close that gap without a session store, without poisoning an aggressive page cache, and without a framework — about a hundred lines of PHP 8.4. The three-part check (equal copies, valid signature, unexpired) is what makes it hold up against the subdomain and MITM variants that break the naive version, and the little fork-based test harness is what keeps a future "simplification" from quietly reopening the hole. If your video admin panel changes state on a cookie alone, add the guard before something loads the wrong forum tab for you.
Top comments (0)