DEV Community

ahmet gedik
ahmet gedik

Posted on

Hardening a PHP Video Admin Panel With CSRF Double-Submit Cookies

A few months ago I watched a bot POST to /admin/videos/delete on a staging box and wipe a test playlist without ever authenticating a form. The request carried a valid session cookie — because the browser attaches cookies to any request to our origin — and my admin panel happily trusted it. That is CSRF in its purest form, and it is embarrassingly easy to trigger against a PHP video panel where every "approve", "delete", and "promote to homepage" action is a state-changing POST guarded only by a login session.

I run the backend for ViralVidVault, a European viral-video discovery service, and our admin panel is where editors approve trending clips, tune category weights, and flush caches. Those endpoints are exactly the kind of high-value, cookie-authenticated actions that CSRF targets. This article walks through how we protect them with the double-submit cookie pattern on PHP 8.4 — why we picked it over server-side synchronizer tokens, the signing detail almost every tutorial gets wrong, and the exact code running in production behind LiteSpeed and Cloudflare.

Why double-submit instead of session-stored tokens

The classic "synchronizer token" pattern stores a random token in the server-side session and compares it against a hidden form field. It works, but it has an operational cost that matters at our scale:

  • Every rendered form has to read and write session state, which means the session file (or Redis key) is locked for the duration of the request. On a busy admin panel with AJAX bulk actions, that serializes requests per user.
  • Our sessions live in SQLite with WAL mode. Writing a token per form-render adds write amplification we would rather avoid.
  • Behind Cloudflare and a LiteSpeed edge cache, anything that forces a per-request session write also tends to force Cache-Control: private, which is fine for admin but leaks into shared code paths if you are not careful.

The double-submit cookie pattern sidesteps server-side storage entirely. The idea:

  1. Issue a random token in a cookie.
  2. Echo the same token into every form as a hidden field (or an X-CSRF-Token header for AJAX).
  3. On a state-changing request, compare the cookie value against the submitted value. If they match, accept; otherwise reject.

The security property comes from the same-origin policy: an attacker's page on evil.example can force the victim's browser to send our cookie, but JavaScript on evil.example cannot read our cookie to copy its value into the request body. So the attacker can satisfy the cookie half but never the body half.

The catch — and this is the part that turns a naive implementation into a real vulnerability — is that a plain double-submit cookie is bypassable if an attacker can set a cookie on your domain (via a compromised subdomain, a MITM on an unencrypted sibling, or a cookie-injection bug). The fix is to sign the token so the server can verify it issued the cookie, turning it into what OWASP calls the signed double-submit cookie. We will do exactly that.

The token: signed, not just random

Here is the token service we use. It generates a random value, binds it to the session ID, and HMAC-signs the pair with a server secret. Nothing is stored server-side.

<?php
declare(strict_types=1);

final class CsrfToken
{
    private const COOKIE_NAME = '__Host-vvv_csrf';
    private const TTL = 7200; // 2 hours

    public function __construct(private readonly string $secret)
    {
        if (strlen($this->secret) < 32) {
            throw new \InvalidArgumentException('CSRF secret must be >= 32 bytes');
        }
    }

    /**
     * Build a signed token bound to the current session.
     * Format: base64(random).base64(expiry).base64(hmac)
     */
    public function issue(string $sessionId): string
    {
        $random = random_bytes(32);
        $expiry = time() + self::TTL;
        $message = $random . '|' . $expiry . '|' . $sessionId;
        $sig = hash_hmac('sha256', $message, $this->secret, true);

        return sprintf(
            '%s.%s.%s',
            $this->b64($random),
            $this->b64((string) $expiry),
            $this->b64($sig)
        );
    }

    /**
     * Verify a submitted token against the session it claims to belong to.
     */
    public function verify(string $token, string $sessionId): bool
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            return false;
        }

        [$randomB64, $expiryB64, $sigB64] = $parts;
        $random = $this->unb64($randomB64);
        $expiry = $this->unb64($expiryB64);
        $sig = $this->unb64($sigB64);

        if ($random === null || $expiry === null || $sig === null) {
            return false;
        }

        if (!ctype_digit($expiry) || (int) $expiry < time()) {
            return false; // expired or malformed
        }

        $message = $random . '|' . $expiry . '|' . $sessionId;
        $expected = hash_hmac('sha256', $message, $this->secret, true);

        // Constant-time comparison — never use == here.
        return hash_equals($expected, $sig);
    }

    public function cookieName(): string
    {
        return self::COOKIE_NAME;
    }

    private function b64(string $raw): string
    {
        return rtrim(strtr(base64_encode($raw), '+/', '-_'), '=');
    }

    private function unb64(string $encoded): ?string
    {
        $decoded = base64_decode(strtr($encoded, '-_', '+/'), true);
        return $decoded === false ? null : $decoded;
    }
}
Enter fullscreen mode Exit fullscreen mode

Three details worth calling out, because they are where implementations go wrong:

  • hash_equals for the signature check. Comparing HMACs with == or === leaks timing information that can, in theory, be used to forge a signature byte by byte. hash_equals is constant-time. This is non-negotiable.
  • Binding to the session ID. By folding $sessionId into the signed message, a token issued for user A's session cannot be replayed in user B's session. This also neutralizes the classic double-submit weakness where an attacker who can plant any valid-looking cookie could pass the check — they would also need to forge an HMAC over the victim's session, which requires the secret.
  • Embedded expiry. Because we store nothing server-side, the token has to carry its own expiry, and that expiry is inside the signed message so it cannot be tampered with.

Setting the cookie correctly

The cookie attributes matter as much as the token. Here is how we emit it, and the flags are deliberate:

<?php
declare(strict_types=1);

final class CsrfCookie
{
    public function __construct(private readonly CsrfToken $csrf) {}

    public function ensure(string $sessionId): string
    {
        $name = $this->csrf->cookieName();

        // Reuse a still-valid cookie so LiteSpeed/Cloudflare don't see a
        // fresh Set-Cookie on every page render (which would bust the cache).
        $existing = $_COOKIE[$name] ?? '';
        if ($existing !== '' && $this->csrf->verify($existing, $sessionId)) {
            return $existing;
        }

        $token = $this->csrf->issue($sessionId);

        setcookie($name, $token, [
            'expires'  => time() + 7200,
            'path'     => '/',
            'secure'   => true,      // __Host- prefix requires it
            'httponly' => false,     // JS must read it for AJAX header
            'samesite' => 'Strict',  // defense in depth
        ]);

        return $token;
    }
}
Enter fullscreen mode Exit fullscreen mode

Decisions:

  • __Host- prefix. A cookie named __Host-* is only accepted by the browser if it is Secure, has Path=/, and has no Domain attribute. That means a compromised or attacker-controlled subdomain cannot overwrite it — subdomains cannot set host-prefixed cookies for the parent. This closes the "attacker sets a cookie" hole that plagues plain double-submit.
  • HttpOnly is false on purpose. The whole point is that our own JavaScript reads this cookie to populate the X-CSRF-Token header on fetch/XHR calls. This is safe because the token's security does not depend on secrecy from same-origin JS — it depends on the same-origin policy preventing cross-origin reads. If an attacker has XSS on our origin, CSRF protection is the least of our problems.
  • SameSite=Strict. This alone blocks most CSRF, but I treat it as defense in depth, not the primary control. SameSite handling has historically varied across browsers and does not cover every navigation case (e.g. some GET-then-POST flows, older clients, or SameSite=None third-party embeds we may add later for oEmbed). Double-submit works regardless of SameSite support.
  • Reusing a valid cookie. Re-issuing on every render would emit a new Set-Cookie header each time, and at ViralVidVault that quietly defeats LiteSpeed's page cache and Cloudflare edge caching. We only mint a new token when the old one is missing or expired.

The verification middleware

Now the enforcement point. Every unsafe HTTP method (POST, PUT, PATCH, DELETE) into the admin area passes through this check before the controller runs. Safe methods (GET, HEAD, OPTIONS) are exempt because they should never change state.

<?php
declare(strict_types=1);

final class CsrfMiddleware
{
    private const SAFE_METHODS = ['GET', 'HEAD', 'OPTIONS'];

    public function __construct(private readonly CsrfToken $csrf) {}

    public function verify(): void
    {
        $method = strtoupper($_SERVER['REQUEST_METHOD'] ?? 'GET');
        if (in_array($method, self::SAFE_METHODS, true)) {
            return;
        }

        $sessionId = session_id();
        if ($sessionId === '') {
            $this->reject('No active session');
        }

        $cookieName = $this->csrf->cookieName();
        $cookieToken = $_COOKIE[$cookieName] ?? '';

        // The submitted token comes from a form field OR the AJAX header.
        $submitted = $_POST['_csrf']
            ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');

        if ($cookieToken === '' || $submitted === '') {
            $this->reject('Missing CSRF token');
        }

        // Double-submit: cookie value MUST equal submitted value...
        if (!hash_equals($cookieToken, $submitted)) {
            $this->reject('CSRF token mismatch');
        }

        // ...AND the token must be a valid signature for THIS session.
        if (!$this->csrf->verify($submitted, $sessionId)) {
            $this->reject('CSRF token invalid or expired');
        }
    }

    private function reject(string $reason): never
    {
        http_response_code(403);
        header('Content-Type: application/json');
        error_log(sprintf('[csrf] rejected: %s ip=%s uri=%s',
            $reason,
            $_SERVER['REMOTE_ADDR'] ?? '-',
            $_SERVER['REQUEST_URI'] ?? '-'
        ));
        echo json_encode(['error' => 'csrf_failed']);
        exit;
    }
}
Enter fullscreen mode Exit fullscreen mode

Notice there are two checks, and both are required:

  1. hash_equals($cookieToken, $submitted) — the literal double-submit comparison. Cookie must equal body/header.
  2. $this->csrf->verify($submitted, $sessionId) — the signature check that proves we issued this token for this session and it has not expired.

A lot of tutorials do only step 1. That is the version that breaks the moment an attacker can plant a cookie on your domain. Doing both means an attacker needs to (a) know a value, (b) place it in the cookie, and (c) place the identical value in the body — while also having a valid HMAC over the victim's session. Without the server secret, that last requirement is infeasible.

Wiring it into the panel

The front controller composes these three pieces. The secret comes from our environment config, not from source — at ViralVidVault it lives alongside the other keys we load per site.

<?php
declare(strict_types=1);

session_start([
    'cookie_secure'   => true,
    'cookie_httponly' => true,
    'cookie_samesite' => 'Strict',
]);

$secret = getenv('CSRF_SECRET') ?: throw new RuntimeException('CSRF_SECRET unset');

$csrf   = new CsrfToken($secret);
$cookie = new CsrfCookie($csrf);
$guard  = new CsrfMiddleware($csrf);

// 1. Enforce on every unsafe admin request.
if (str_starts_with($_SERVER['REQUEST_URI'] ?? '', '/admin')) {
    $guard->verify();
}

// 2. Make a token available to templates for THIS render.
$token = $cookie->ensure(session_id());

// In your layout, expose it to forms and JS:
//   <input type="hidden" name="_csrf" value="<?= htmlspecialchars($token) ?>">
//   <meta name="csrf-token" content="<?= htmlspecialchars($token) ?>">
Enter fullscreen mode Exit fullscreen mode

And on the client, every mutating fetch reads the cookie (or the meta tag) and sends it back:

function csrfToken() {
  const meta = document.querySelector('meta[name="csrf-token"]');
  return meta ? meta.content : '';
}

async function adminAction(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) {
    // token expired mid-session — reload to mint a fresh one
    window.location.reload();
    return;
  }
  return res.json();
}
Enter fullscreen mode Exit fullscreen mode

Edge cases that bit us in production

Getting the happy path working took an afternoon. The rough edges took longer.

  • Cache stripping the header. Cloudflare and LiteSpeed will happily cache an admin page if you let them, and a cached page carries a stale token that no longer matches a rotated cookie. We send Cache-Control: no-store, private on everything under /admin and only cache truly public routes. The token-reuse logic in CsrfCookie::ensure() is what keeps public pages cacheable — no Set-Cookie churn.
  • Token expiry during a long editing session. An editor who leaves a form open for three hours will fail the check on submit because the embedded expiry lapsed. Rather than silently losing their work, the client-side 403 handler reloads to mint a fresh token, and for long forms we refresh the token via a lightweight GET /admin/csrf/refresh on an interval.
  • Multiple tabs. Because the token is derived from the session and reused while valid, two tabs share the same cookie and both keep working. If we rotated on every render, one tab would invalidate the other's hidden field. Reuse fixes this for free.
  • Login and logout. The session ID changes on session_regenerate_id() (which you should call on privilege change). Since tokens are bound to the session ID, old tokens stop verifying automatically after login — exactly what you want. Just make sure you call ensure() after regenerating.
  • File uploads. multipart/form-data posts still carry $_POST['_csrf'] as long as the hidden field is inside the form, so no special handling is needed — but do confirm your upload size limit doesn't truncate the body before PHP parses the field.

What this does and does not protect

Be honest about the threat model. Signed double-submit cookies stop cross-site request forgery: a malicious third-party page cannot forge a state-changing request against our admin panel, because it cannot read our token to echo it back, and it cannot forge our HMAC.

They do not protect against:

  • XSS. If an attacker runs script on our origin, they can read the token and craft valid requests. CSRF defense assumes your origin is not already compromised. Content-Security-Policy and output escaping are separate, mandatory controls.
  • Stolen session cookies. If the session itself leaks, the attacker is authenticated. Use Secure, HttpOnly, SameSite on the session cookie and rotate on privilege change.
  • Server-side request forgery or broken access control. Different vulnerability classes entirely. A CSRF token on an endpoint that fails to check whether the editor is allowed to delete that video is still broken.

Conclusion

For a cookie-authenticated PHP admin panel, the signed double-submit cookie is the pragmatic sweet spot: no server-side token storage, no per-render session writes, cache-friendly, and — once you add the HMAC and the __Host- prefix — as strong as a synchronizer token against every practical CSRF vector. The three rules that separate a real defense from a checkbox are: sign the token and bind it to the session, compare with hash_equals, and reuse a valid cookie so you don't fight your own cache. Get those right and the bot that wiped my staging playlist simply gets a 403.

The code above is close to what protects the editor tooling at ViralVidVault today, running on PHP 8.4 behind LiteSpeed and Cloudflare. Adapt the cookie name and TTL to your setup, keep your CSRF_SECRET out of source control, and log your rejections — the rejection log is the first place you will notice both attacks and your own misconfigured caches.

Top comments (0)