DEV Community

ahmet gedik
ahmet gedik

Posted on

Hardening a PHP 8.4 Video Admin Panel with CSRF Double-Submit Cookies

A stranger's browser can POST to your admin panel without ever seeing your login screen. Last quarter I found a request in the TopVideoHub access logs that hit /admin/videos/delete with a valid session cookie but a Referer pointing at a forum thread. The session belonged to me. I had been logged into the admin panel in one tab and clicked a link in another. Nothing was deleted that time because the endpoint happened to require a POST body field the attacker didn't guess, but the lesson was blunt: session cookies alone authenticate the browser, not the intent. That gap is what CSRF exploits, and for a video aggregator where a single admin action re-indexes thousands of CJK-tokenized rows in SQLite FTS5, an accidental or malicious cross-site write is expensive to undo.

This is a walkthrough of how I moved the admin panel from a fragile synchronizer-token setup to a stateless double-submit cookie scheme, why the naive version of double-submit is broken, and the exact PHP 8.4 code that runs behind LiteSpeed and Cloudflare today.

Why the synchronizer token pattern hurt us

The classic CSRF defense stores a random token in the server-side session and embeds a copy in every form. On submit, you compare the posted token against $_SESSION['csrf']. It works, but it has a cost that shows up at scale.

Our admin panel is used from a laptop, a phone on the train, and occasionally a second browser when I'm testing region-specific trending feeds. Each of those is a separate session row. The synchronizer pattern forces every form render to touch session state, and because our sessions are file-backed, concurrent tabs kept stepping on each other: open two tabs, submit the older one, and the token had already rotated. Admins got "invalid token" errors on legitimate saves. The workaround people reach for — never rotating the token — throws away half the protection.

The deeper problem: session-backed tokens don't compose well with caching. LiteSpeed serves the public site from its page cache, and any HTML that embeds a per-session token can't be cached at the edge. I wanted admin auth logic that didn't require reading or writing session state on every single request, so the read path stays cheap.

Double-submit cookies solve this by keeping the token entirely on the client. The server never stores it. That's the appeal and, done carelessly, the trap.

How double-submit actually works

The pattern is simple to state:

  1. On a safe request (GET), the server sets a cookie containing a random token.
  2. Every state-changing form or fetch includes that same token as a request parameter or header.
  3. On submit, the server checks that the cookie value and the submitted value match.

The security argument rests on the same-origin policy: an attacker's page on evil.example can cause the victim's browser to send your cookies (cookies travel with the request automatically), but JavaScript on evil.example cannot read your cookie to copy it into a matching form field. So they can send the cookie but not the matching body token. Mismatch → reject.

That argument has a giant hole, and most tutorials skip it.

The subdomain problem that breaks naive double-submit

Cookies do not respect the same-origin policy the way JavaScript does. Cookies obey domain and path scoping, which is looser. If an attacker controls or can inject content into any subdomain of your registrable domain — say a user-content subdomain, a stale marketing host, or a shared hosting neighbor under *.topvideohub.com — they can set a cookie with Domain=topvideohub.com that shadows your CSRF cookie. Now the attacker chooses the token value, sets it as both the cookie (via the injected subdomain) and the form field (in their own attack page), and the values match. Defense defeated.

This is why OWASP's current guidance is explicit: plain double-submit is insufficient. You need the signed (or HMAC-bound) double-submit cookie. The token must be cryptographically tied to something the attacker cannot forge — a server-side secret — so that a token the attacker planted via a shadowed cookie fails verification even though the two copies match each other.

The second fix is cookie naming. Using the __Host- prefix forces the cookie to be Secure, host-only (no Domain attribute), and path /. Browsers refuse to set a __Host--prefixed cookie with a Domain, so a subdomain literally cannot write it. Combined with the HMAC binding, that closes the hole from both sides.

The token design

Here is the scheme I settled on:

  • Generate 32 random bytes per session identity (I bind it to the session id so a token only validates for the browser it was issued to).
  • Compute HMAC-SHA256(message = session_id . '!' . random, key = APP_SECRET).
  • The cookie and the form field both carry random . '.' . hmac.
  • On verify: recompute the HMAC from the posted random and the current session id, compare in constant time, and also constant-time compare cookie vs. form value.

The random part gives per-request unpredictability; the HMAC binds it to the session and to a server-only secret so a shadowed cookie can't produce a valid pair. Because verification only needs APP_SECRET and the session id, the check is stateless — no CSRF token table, no session write on render.

The PHP 8.4 implementation

This is the core class. It uses typed constants and readonly properties, runs under PHP 8.4, and assumes APP_SECRET comes from the environment (never hardcoded, never deployed in a cacheable file).

<?php
declare(strict_types=1);

final class CsrfGuard
{
    private const string COOKIE_NAME = '__Host-vw_csrf';
    private const string FIELD_NAME  = 'csrf_token';
    private const string HEADER_NAME = 'HTTP_X_CSRF_TOKEN';
    private const int    RANDOM_BYTES = 32;
    private const int    TTL_SECONDS  = 7200; // 2h admin session window

    public function __construct(
        private readonly string $secret,
        private readonly string $sessionId,
    ) {
        if (strlen($this->secret) < 32) {
            throw new RuntimeException('APP_SECRET too short');
        }
    }

    /** Issue a token, set the cookie, return the value to embed in forms. */
    public function issue(): string
    {
        $random = bin2hex(random_bytes(self::RANDOM_BYTES));
        $mac    = $this->sign($random);
        $token  = $random . '.' . $mac;

        setcookie(self::COOKIE_NAME, $token, [
            'expires'  => time() + self::TTL_SECONDS,
            'path'     => '/',
            'secure'   => true,      // required by __Host- prefix
            'httponly' => true,
            'samesite' => 'Lax',     // defense in depth, not the primary control
        ]);

        return $token;
    }

    private function sign(string $random): string
    {
        // Bind the token to the session id + server secret.
        $message = $this->sessionId . '!' . $random;
        return hash_hmac('sha256', $message, $this->secret);
    }

    /** Verify a submitted request. Returns true only on a valid pair. */
    public function verify(): bool
    {
        $cookie = $_COOKIE[self::COOKIE_NAME] ?? '';
        $sent   = $this->tokenFromRequest();

        if ($cookie === '' || $sent === '') {
            return false;
        }
        // Cookie and submitted value must be identical.
        if (!hash_equals($cookie, $sent)) {
            return false;
        }
        // And the HMAC must validate against our secret + session.
        return $this->isSignatureValid($sent);
    }

    private function isSignatureValid(string $token): bool
    {
        $parts = explode('.', $token, 2);
        if (count($parts) !== 2) {
            return false;
        }
        [$random, $mac] = $parts;
        $expected = $this->sign($random);
        return hash_equals($expected, $mac);
    }

    private function tokenFromRequest(): string
    {
        // Accept the token from a form field or a header (for fetch()).
        if (isset($_POST[self::FIELD_NAME]) && is_string($_POST[self::FIELD_NAME])) {
            return $_POST[self::FIELD_NAME];
        }
        return $_SERVER[self::HEADER_NAME] ?? '';
    }
}
Enter fullscreen mode Exit fullscreen mode

A few decisions worth calling out. hash_equals() is used for every comparison — both the cookie-vs-field check and the HMAC check — because a naive === on the MAC leaks timing information about how many leading bytes matched. The __Host- prefix is not cosmetic; drop it and the whole subdomain defense evaporates. And SameSite=Lax is included but I treat it as secondary. Lax still allows top-level GET navigations, and not every user's browser enforces it identically, so the HMAC double-submit remains the load-bearing control.

Wiring it into the router

Our front controller dispatches admin routes through a single middleware. GET requests get a freshly issued token made available to the view layer; unsafe methods must pass verification before the controller ever runs.

<?php
declare(strict_types=1);

function csrfMiddleware(CsrfGuard $guard, string $method): void
{
    $safe = in_array($method, ['GET', 'HEAD', 'OPTIONS'], true);

    if ($safe) {
        // Make the current token available to templates.
        $GLOBALS['csrf_token'] = $_COOKIE['__Host-vw_csrf'] ?? $guard->issue();
        return;
    }

    if (!$guard->verify()) {
        http_response_code(419); // "page expired" convention
        header('Content-Type: application/json');
        echo json_encode([
            'error' => 'csrf_validation_failed',
            'hint'  => 'Reload the admin page and retry.',
        ], JSON_THROW_ON_ERROR);
        exit;
    }
}

// In the front controller:
session_start(['cookie_httponly' => true, 'cookie_secure' => true]);
$guard = new CsrfGuard(
    secret: getenv('APP_SECRET') ?: throw new RuntimeException('APP_SECRET missing'),
    sessionId: session_id(),
);
csrfMiddleware($guard, $_SERVER['REQUEST_METHOD']);
Enter fullscreen mode Exit fullscreen mode

On a GET we reuse the existing cookie if one is present rather than reissuing on every render — that avoids the concurrent-tab rotation problem that plagued the synchronizer pattern, because the token is stable for its two-hour lifetime and any tab holds a valid copy.

The template side is unremarkable but must never be skipped:

<form method="post" action="/admin/videos/delete">
    <input type="hidden" name="csrf_token"
           value="<?= htmlspecialchars($GLOBALS['csrf_token'], ENT_QUOTES) ?>">
    <input type="hidden" name="video_id" value="<?= (int) $video->id ?>">
    <button type="submit">Delete</button>
</form>
Enter fullscreen mode Exit fullscreen mode

For the JavaScript-driven parts of the panel — the drag-to-reorder trending lists, the bulk re-tokenize button — the token rides in a header instead:

function adminFetch(url, body) {
  // Cookie is set to Secure+HttpOnly? Then read the token from the DOM,
  // where the server rendered it, not from document.cookie.
  const token = document.querySelector('meta[name="csrf-token"]').content;
  return fetch(url, {
    method: 'POST',
    credentials: 'same-origin',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': token,
    },
    body: JSON.stringify(body),
  });
}
Enter fullscreen mode Exit fullscreen mode

Note that because the cookie is HttpOnly, JavaScript cannot read it via document.cookie — which is exactly what we want against XSS-assisted theft. The token the JS sends comes from a <meta> tag the server rendered, and the server compares that against the HttpOnly cookie it can read. The two paths converge server-side.

The Cloudflare and LiteSpeed gotchas

Running behind Cloudflare in Flexible SSL mode created two surprises worth documenting.

First, the Secure cookie flag. Because Cloudflare terminates TLS and talks to origin over plain HTTP in Flexible mode, PHP's $_SERVER['HTTPS'] is empty at origin, and some frameworks then refuse to set a Secure cookie because they think the connection isn't secure. The browser-facing connection is HTTPS, so the cookie must be Secure. I set it unconditionally in the setcookie() options rather than trusting a server variable, and I trust X-Forwarded-Proto only for redirect logic, never for the cookie flag.

Second, caching. The admin panel must never be cached — not by LiteSpeed's page cache, not by Cloudflare. A cached admin page would serve one admin's CSRF cookie header to another visitor. I exclude the admin path explicitly and send hard no-store headers:

# .htaccess — keep admin uncacheable
<IfModule LiteSpeed>
    RewriteRule ^admin/ - [E=Cache-Control:no-cache]
    RewriteRule ^ibt/  - [E=Cache-Control:no-cache]
</IfModule>
Enter fullscreen mode Exit fullscreen mode

and at the PHP layer for admin responses:

header('Cache-Control: no-store, private');
header('Vary: Cookie');
Enter fullscreen mode Exit fullscreen mode

The Vary: Cookie is belt-and-suspenders so any intermediate that ignores no-store at least keys on the cookie. In LiteSpeed the <IfModule LiteSpeed> guard matters because Apache would choke on some of these directives — a lesson learned the hard way when a comma inside an [E=...] flag returned a 500 on an Apache host while working fine on LiteSpeed.

Testing that it actually rejects

A CSRF control you never tested rejecting is a control you don't have. I added three checks to the suite: a valid pair passes, a missing field fails, and — the important one — a forged token where cookie and field match each other but the HMAC is bogus still fails. That last test is what proves the subdomain-shadowing defense works.

<?php
declare(strict_types=1);
use PHPUnit\Framework\TestCase;

final class CsrfGuardTest extends TestCase
{
    private const string SECRET = 'test-secret-that-is-long-enough-32b';

    public function testValidPairPasses(): void
    {
        $guard = new CsrfGuard(self::SECRET, 'sess-abc');
        $token = $guard->issue();          // sets $_COOKIE via setcookie in real flow
        $_COOKIE['__Host-vw_csrf'] = $token;
        $_POST['csrf_token'] = $token;
        self::assertTrue($guard->verify());
    }

    public function testMatchingButUnsignedPairFails(): void
    {
        // Simulates a subdomain-shadowed cookie: attacker picks the value,
        // sets it as both cookie and field. They match each other, but the
        // HMAC was not produced with our secret + session.
        $guard = new CsrfGuard(self::SECRET, 'sess-abc');
        $forged = bin2hex(random_bytes(32)) . '.' . str_repeat('0', 64);
        $_COOKIE['__Host-vw_csrf'] = $forged;
        $_POST['csrf_token'] = $forged;
        self::assertFalse($guard->verify());
    }

    public function testTokenIssuedForAnotherSessionFails(): void
    {
        $issuer = new CsrfGuard(self::SECRET, 'sess-victim');
        $token  = $issuer->issue();
        $_COOKIE['__Host-vw_csrf'] = $token;
        $_POST['csrf_token'] = $token;
        // Verify under a different session id -> HMAC won't match.
        $attacker = new CsrfGuard(self::SECRET, 'sess-attacker');
        self::assertFalse($attacker->verify());
    }
}
Enter fullscreen mode Exit fullscreen mode

The second and third tests are the ones that give me confidence. The first only proves the happy path; the rejection tests prove the security property.

What I'd tell my past self

  • Plain double-submit is not enough. Bind the token to a server secret with HMAC, or a subdomain foothold breaks it.
  • Use the __Host- cookie prefix. It forbids a Domain attribute, which is precisely the vector a shadowing subdomain needs.
  • Constant-time compare everything — the field-vs-cookie check and the MAC.
  • Keep SameSite=Lax as defense in depth, never as your only control; enforcement varies and top-level GET navigations still pass.
  • Never cache admin responses. Behind Cloudflare Flexible SSL, set Secure cookies unconditionally rather than sniffing $_SERVER['HTTPS'].
  • Write the rejection tests, not just the acceptance test. A forged-but-matching pair must fail, and a token from another session must fail.

The stateless design paid off beyond security: the admin read path no longer writes session state on every render, concurrent tabs stopped colliding, and the verification logic is small enough to audit in one sitting. For a video aggregator where one careless cross-site POST can trigger a full FTS5 re-index across regions, that small, auditable, correctly-bound token is worth far more than the hour it took to build.

Top comments (0)