DEV Community

ahmet gedik
ahmet gedik

Posted on

Building CSRF Double-Submit Cookie Protection for PHP Video Admin Panels

Last month a researcher sent me a single HTML file. It contained one hidden form and three lines of JavaScript. If any logged-in admin of our platform opened that page in the same browser they used for our dashboard, the form would silently POST to /admin/videos/delete and pull a trending clip off the homepage during peak traffic. No stolen password. No XSS. No malware. Just a forged request riding on the admin's own session cookie, which the browser attaches automatically to any request pointed at our domain.

That is Cross-Site Request Forgery, and for a video admin panel it is nastier than it sounds. Our state-changing endpoints do real damage in one click: delete a video, reorder the featured carousel, ban a channel, purge a cache, flip a video's GDPR consent flag. A single forged POST during a viral spike can cost hours of traffic. At ViralVidVault we run a deliberately lean PHP 8.4 stack — SQLite in WAL mode, LiteSpeed, and a thin Cloudflare Workers layer in front — and I want to walk through exactly how we shut this class of attack down with the double-submit cookie pattern, including the subtle mistakes that make a naive implementation completely worthless.

Why your session cookie is the vulnerability, not the defense

The reflex reaction is "but the admin is authenticated, the session cookie proves who they are." That is precisely the problem. The browser sends your session cookie on every request to your origin, no matter which site initiated it. When evil-video-scraper.example submits a form to https://viralvidvault.com/admin/videos/delete, the browser cheerfully attaches your admin session cookie. From PHP's perspective the request is perfectly authenticated. $_SESSION['admin_id'] is populated. The request looks identical to a legitimate one.

Authentication answers "who is this user?" CSRF protection answers a different question: "did this specific request actually originate from a page I served?" A session cookie can never answer the second question, because cookies are ambient — the browser attaches them based on destination, not origin.

SameSite=Lax cookies help enormously here and you should absolutely set them, but they are not a complete defense. Lax still permits top-level GET navigations to carry the cookie, and older or misconfigured browsers, plus certain proxy setups, can weaken the guarantee. Defense in depth means you do not bet an admin panel on a single cookie attribute. You want an explicit, per-request secret that a cross-origin attacker cannot read or predict.

The double-submit pattern in one paragraph

Here is the whole idea. When you render an admin page, you generate a random token. You put that token in two places: a cookie, and a hidden field inside every form (or an HTTP header for AJAX). When the request comes back, the server checks that the cookie value and the submitted value match. An attacker on another origin can force the browser to send your cookies, but the same-origin policy stops them from reading your cookie value, so they cannot copy it into their forged form field. No match, no request. The genius of the pattern is that it is stateless — the server does not have to store a token per session, which matters when you are keeping a SQLite database lean and do not want a csrf_tokens table growing without bound.

That is the textbook version. The textbook version is also exploitable, and most tutorials stop right before the part that matters.

The flaw in naive double-submit and how HMAC fixes it

Plain double-submit assumes an attacker cannot set cookies on your domain. That assumption breaks more often than people think. If an attacker controls any subdomain — a stale blog. host, a forgotten staging. box, a compromised marketing microsite — they can often set a cookie scoped to the parent domain. That is a cookie injection attack. The attacker sets their own known token as the csrf cookie, puts the same known value in their forged form, and now cookie and form match perfectly. Your check passes. You are owned.

The fix is the signed double-submit cookie. Instead of a bare random string, the token is cryptographically bound to the user's session using an HMAC that only your server can compute. An attacker who injects a cookie cannot forge a valid signature, and a token minted for someone else's session fails the binding check. You get the statelessness of double-submit with the integrity of a server secret. This is the variant recommended by the current OWASP guidance, and it is the one we run in production.

Here is the token service. It is plain PHP 8.4, no framework, and it is runnable as-is:

<?php
declare(strict_types=1);

final class CsrfToken
{
    private const TTL = 7200; // 2 hours, matches our admin idle timeout

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

    /**
     * Mint a token: random value + expiry, signed with an HMAC that
     * binds it to this session id. Format: random.expires.mac
     */
    public function issue(string $sessionId): string
    {
        $random  = bin2hex(random_bytes(32));
        $expires = time() + self::TTL;
        $mac     = $this->sign($random, $expires, $sessionId);

        return $random . '.' . $expires . '.' . $mac;
    }

    /** Constant-time verification of structure, expiry, and signature. */
    public function verify(string $token, string $sessionId): bool
    {
        $parts = explode('.', $token);
        if (count($parts) !== 3) {
            return false;
        }
        [$random, $expires, $mac] = $parts;

        if (!ctype_digit($expires) || (int) $expires < time()) {
            return false;
        }

        $expected = $this->sign($random, (int) $expires, $sessionId);

        return hash_equals($expected, $mac);
    }

    private function sign(string $random, int $expires, string $sessionId): string
    {
        $payload = $random . '.' . $expires . '.' . $sessionId;

        return hash_hmac('sha256', $payload, $this->secret);
    }
}
Enter fullscreen mode Exit fullscreen mode

Three details carry the security here, and none of them are optional:

  • random_bytes() is a CSPRNG. Never use mt_rand(), uniqid(), or md5(time()) — a predictable token is a token an attacker can forge without ever reading your cookie.
  • hash_equals() is a constant-time comparison. A plain === on the MAC leaks timing information that a patient attacker can use to reconstruct a valid signature byte by byte.
  • The session id goes into the signed payload. That is the line that defeats cookie injection: a token is only valid for the exact session it was minted for.

Setting the cookie with attributes that actually hold

The cookie is where beginners quietly undo all their work. The attributes matter as much as the token value. Here is how we set ours:

<?php
declare(strict_types=1);

function send_csrf_cookie(string $token): void
{
    // The __Host- prefix is enforced by the browser: it REQUIRES
    // Secure, Path=/, and NO Domain attribute. That makes it
    // impossible for a sibling subdomain to overwrite this cookie.
    setcookie('__Host-csrf', $token, [
        'expires'  => 0,      // session cookie, dies with the browser
        'path'     => '/',
        'secure'   => true,   // mandatory for __Host- and correct anyway
        'httponly' => false,  // JS must read it for the AJAX header path
        'samesite' => 'Lax',  // second, independent layer of defense
    ]);
}
Enter fullscreen mode Exit fullscreen mode

The __Host- prefix is the unsung hero. A browser refuses to accept a __Host- cookie unless it is Secure, has Path=/, and has no Domain attribute. That last part is the whole point: a cookie with no Domain is locked to the exact host that set it and cannot be written by sub.viralvidvault.com. It closes the cookie-injection door at the browser level, on top of the HMAC binding closing it at the application level. Belt and suspenders.

One genuine trade-off: httponly is false here because our AJAX endpoints read the cookie in JavaScript to echo it back as a header. If your panel is 100% classic form posts, set httponly to true and rely on the server-side hidden field instead — the token stays out of reach of any injected script. Pick based on how your panel actually submits.

Verifying every state-changing request

Verification runs as a single guard at the top of the admin router, before any controller logic touches the database. The rule is blunt: safe methods pass, everything else must prove itself.

<?php
declare(strict_types=1);

function verify_csrf(CsrfToken $csrf, string $sessionId): void
{
    $method = $_SERVER['REQUEST_METHOD'] ?? 'GET';

    // GET/HEAD/OPTIONS must never mutate state, so they are exempt.
    // If a GET in your app changes data, fix THAT first.
    if (in_array($method, ['GET', 'HEAD', 'OPTIONS'], true)) {
        return;
    }

    $cookie    = $_COOKIE['__Host-csrf'] ?? '';
    $submitted = $_POST['csrf_token'] ?? ($_SERVER['HTTP_X_CSRF_TOKEN'] ?? '');

    if ($cookie === '' || $submitted === '') {
        deny('CSRF token missing');
    }

    // 1. The two submissions must match: this is the "double submit".
    if (!hash_equals($cookie, $submitted)) {
        deny('CSRF token mismatch');
    }

    // 2. The token must be an unexpired, session-bound, signed value.
    if (!$csrf->verify($submitted, $sessionId)) {
        deny('CSRF token invalid');
    }
}

function deny(string $reason): never
{
    http_response_code(403);
    header('Content-Type: text/plain; charset=utf-8');
    error_log('CSRF rejected: ' . $reason . ' ip=' . ($_SERVER['REMOTE_ADDR'] ?? '?'));
    exit('Forbidden');
}
Enter fullscreen mode Exit fullscreen mode

Both checks are load-bearing. Check one (cookie equals submission) is the classic double-submit and stops the basic cross-origin forgery, because the attacker cannot read the cookie to copy it into the form. Check two (valid signature bound to session) stops the cookie-injection escalation. Drop either and you have a hole. And note the exemption logic: it exempts by HTTP method, not by URL. If a single GET endpoint in your panel mutates state, an attacker just uses that and skips CSRF entirely. Audit for that before you ship — read-only GET is a security invariant, not a style preference.

Wiring it into the panel

On the render side, every page that will host a mutating form issues a token, sets the cookie, and embeds the value. The embed must be HTML-escaped even though the token is your own data — defense in depth means you never emit an unescaped attribute value, full stop.

<?php
$csrf  = new CsrfToken($_ENV['CSRF_SECRET']);
$token = $csrf->issue(session_id());
send_csrf_cookie($token);
?>
<form method="post" action="/admin/videos/delete">
    <input type="hidden" name="csrf_token"
           value="<?= htmlspecialchars($token, ENT_QUOTES, 'UTF-8') ?>">
    <input type="hidden" name="video_id" value="<?= (int) $video['id'] ?>">
    <button type="submit" class="btn-danger">Delete video</button>
</form>
Enter fullscreen mode Exit fullscreen mode

For our JSON endpoints — carousel reordering, bulk tag edits, the trend-tracking toggles — the browser reads the cookie and echoes it in the X-CSRF-Token header, which the middleware already checks:

function csrfToken() {
  return document.cookie
    .split('; ')
    .find((c) => c.startsWith('__Host-csrf='))
    ?.split('=')[1] ?? '';
}

await fetch('/admin/videos/reorder', {
  method: 'POST',
  headers: {
    'Content-Type': 'application/json',
    'X-CSRF-Token': csrfToken(),
  },
  body: JSON.stringify({ order: newOrder }),
});
Enter fullscreen mode Exit fullscreen mode

Because the same-origin policy blocks a cross-origin page from reading document.cookie for our domain, an attacker's script simply cannot populate that header. The forged fetch arrives with no token and gets a 403 before it reaches SQLite.

The Cloudflare Workers and LiteSpeed layer

Running behind Cloudflare gives us a place to reject the most obvious forgeries before they ever spend a PHP worker. We push an Origin check to the edge as a cheap first filter. This does not replace the token check — Origin headers can be absent on some legitimate requests, so we never require it in PHP — but at the edge we can reject anything that explicitly announces a wrong origin:

export default {
  async fetch(request) {
    const url = new URL(request.url);
    const unsafe = !['GET', 'HEAD', 'OPTIONS'].includes(request.method);

    if (unsafe && url.pathname.startsWith('/admin/')) {
      const origin = request.headers.get('Origin');
      const allowed = 'https://viralvidvault.com';

      // Reject clearly cross-origin writes before they hit the origin.
      if (origin && origin !== allowed) {
        return new Response('Blocked cross-origin request', { status: 403 });
      }
    }
    return fetch(request);
  },
};
Enter fullscreen mode Exit fullscreen mode

Two stack-specific notes. First, LiteSpeed's page cache must never cache admin routes — a cached page could serve a stale token that fails verification and locks out real admins; we exclude /admin/ and /ibt from the cache rules entirely. Second, because we run SQLite in WAL mode, the CSRF check being fully stateless is a real win: there is no token row to INSERT and no cleanup job, so the write path stays purely for the actual mutation, and WAL contention stays low even during a fetch-cron burst.

Testing that it actually works

A CSRF defense you have not tried to break is a hope, not a control. The manual test is fast: log in, open dev tools, copy a valid delete request as curl, then run it once unmodified (should succeed), and once with the csrf_token field stripped or altered (must return 403). Then test the escalation: manually set a __Host-csrf cookie to an attacker-chosen value and submit that same value in the form. With the naive pattern this passes; with the signed pattern it must fail on the signature check. If both malicious cases return 403 and the legitimate one succeeds, your two layers are doing their jobs. Wire those three cases into an automated request test so a future refactor cannot silently remove the guard.

Rollout notes and the gotchas that bit us

  • Rotate the secret carefully. Changing CSRF_SECRET invalidates every outstanding token, so mid-session admins get a spurious 403 on their next save. Rotate during a low-traffic window, or support a short grace period that accepts the previous secret too.
  • Watch token expiry versus long edit sessions. A two-hour TTL means an admin who leaves a long-form editorial draft open can submit into an expired token. We refresh the token via a lightweight heartbeat and, on a 403, re-fetch a fresh token and let the user retry without losing their form.
  • Do not put the token in the URL. Query strings leak into access logs, Referer headers, and browser history. Header or POST body only.
  • Keep GET pure. This is worth repeating because it is the most common real-world bypass: any GET that mutates state is a CSRF hole your token scheme never even sees.
  • Log rejections. A sudden spike in CSRF 403s is either a broken deploy or an active probing attempt. Either way you want to know within minutes, not at the next post-mortem.

Conclusion

CSRF is unglamorous. It does not make headlines the way an RCE does, and it is exactly the kind of thing a small team running its own PHP admin panel forgets until someone emails a proof-of-concept. The signed double-submit cookie pattern is the sweet spot for a lean stack: no server-side token storage to bloat SQLite, a stateless HMAC that survives cookie-injection attacks, and a __Host- prefixed cookie that browsers themselves refuse to let a sibling subdomain overwrite. Layer an Origin check at the Cloudflare edge and keep your GET endpoints read-only, and a forged delete from a malicious page becomes a boring 403 in your logs instead of a missing video during your biggest traffic hour. It took us an afternoon to implement and it has quietly rejected every cross-origin write attempt since. That is the best kind of security work — invisible, cheap, and done once.

Top comments (0)