Double-Submit Cookie & Stateless Tokens

Chapter 6
Double-Submit Cookie & Stateless Tokens
Cookie-to-header matching, when to use it, and the subdomain pitfall

The synchronizer token (Chapter 5) requires the server to store a token per session. That's fine for stateful apps, but adds friction for stateless backends and APIs that don't keep server-side session state. The double-submit cookie pattern achieves CSRF protection without server-side storage — at the cost of some subtle pitfalls this chapter makes explicit.

The Core Trick

The server sends the CSRF token as a cookie, and the client-side JavaScript reads that cookie and copies its value into a request header (or body field) on each state-changing request. The server then checks that the cookie value and the header value match. No server-side token store is needed — the server just compares the two copies the request itself carries.

// server sets the token cookie (readable by JS — NOT HttpOnly here) res.cookie("csrf", token, { sameSite: "lax", secure: true }); // client copies the cookie value into a header on each request fetch("/transfer", { method: "POST", headers: { "X-CSRF-Token": readCookie("csrf") }, credentials: "include", }); // server validates: do the cookie copy and the header copy match? if (req.cookies.csrf !== req.headers["x-csrf-token"]) return res.sendStatus(403);

Why It Stops CSRF

The cookie rides automatically on a forged cross-site request (ambient authority) — but the attacker cannot read it to copy its value into the header, because of the Same-Origin Policy (Chapter 2). So a forged request arrives with the cookie but no matching header, and validation fails. The defence rests entirely on the asymmetry: the cookie is auto-sent, but only same-origin JavaScript can read it to set the header.

Why "header" matters — and why this resists forgery
A cross-site attacker can make the browser send the cookie, but cannot set a custom request header on a simple cross-site request (and setting one forces a CORS preflight they can't pass, Chapter 2). So requiring the token in a header does double duty: the attacker can neither read the value nor place it in the header. That's why double-submit implementations favour a custom header over a hidden form field — it adds the preflight barrier on top of the read barrier.

Synchronizer vs Double-Submit

Synchronizer tokenDouble-submit cookie
Server-side storagerequired (token per session)none — stateless
Validationsubmitted token vs stored tokencookie copy vs header copy
Best fitstateful, server-rendered appsstateless backends, SPAs/APIs
Main weaknessstorage cost; per-request fragilitysubdomain / cookie-injection issues

The Big Pitfall: Subdomains Can Write Your Cookie

Double-submit's security assumes the attacker can't control the cookie value. But cookies have a weaker isolation model than the same-origin policy: a sibling or compromised subdomain can set a cookie on the parent domain. If evil.example.com (or an XSS on any subdomain) can write the csrf cookie for .example.com, an attacker can plant a known value — then put that same known value in the header of a forged request, and the two match.

"Naive" double-submit is forgeable via cookie injection
This is the well-known weakness: if an attacker can set the CSRF cookie (via a subdomain they control, a sibling-domain XSS, or a network position on a non-HTTPS connection), they know the token value and can supply a matching header — defeating the check. The naive "cookie value == header value" comparison trusts the cookie's integrity, which the cookie model doesn't guarantee. The fix is the signed (or HMAC) double-submit: the token isn't a bare random value but is cryptographically bound to the session, so a planted cookie value the server didn't issue won't validate. Modern libraries (e.g. csrf-csrf) implement this signed variant — don't roll the naive version yourself.

The Signed / HMAC Variant

The hardened form binds the token to the user's session so it can't be forged by merely setting a cookie:

// token = random value + HMAC over (session id + value), server secret const message = sessionId + "!" + value; const token = value + "." + hmacSHA256(secret, message); // on validation, the server recomputes the HMAC from the SESSION it trusts // a cookie value an attacker planted won't carry a valid HMAC -> rejected

Now the server doesn't blindly trust the cookie; it verifies the token is one it issued for this session. This restores the property that an attacker who can only set a cookie (but not read the session secret) can't produce a valid token — closing the cookie-injection hole while staying stateless.

The SPA-Friendly Variant You've Probably Seen

Frameworks like Angular and Django (with the right config) ship a double-submit flow out of the box: the server sets a cookie named XSRF-TOKEN, and the framework's HTTP client automatically reads it and sends it back as an X-XSRF-TOKEN header on every request. If you've used Angular's HttpClient and wondered where CSRF protection came from, this is it — double-submit, automated.

The token cookie is deliberately NOT HttpOnly — and that's correct here
A reasonable instinct from Chapter 4 is "make all auth cookies HttpOnly." But the double-submit token cookie must be readable by JavaScript so the client can copy it into the header — so it's intentionally non-HttpOnly. This is safe provided you don't also have XSS (which would let an attacker read it anyway — and XSS defeats every CSRF defence, Chapter 4). Your session cookie should still be HttpOnly; only the CSRF token cookie is exposed to JS. Don't confuse the two.

Hands-On Exercises

Exercise 1

Implement a basic double-submit cookie check in Express (set a JS-readable token cookie; validate that an X-CSRF-Token header equals the cookie). Then explain precisely why a cross-site forged request fails this check despite the cookie being auto-sent.

📄 View solution
Exercise 2

Describe the subdomain / cookie-injection attack that defeats naive double-submit. Walk through how an attacker controlling evil.example.com can make a forged request pass the cookie==header check, and explain how the signed/HMAC variant prevents it.

📄 View solution
Exercise 3

Compare synchronizer and double-submit on storage, statelessness, and main weakness, and recommend which to use for (a) a server-rendered Rails app and (b) a stateless JSON API behind a SPA. Then explain why the CSRF token cookie is intentionally not HttpOnly and when that becomes dangerous.

📄 View solution

Chapter 6 Quick Reference

  • Double-submit cookie — token sent as a cookie; client copies it into a header; server checks cookie == header. No server storage.
  • Stops CSRF because the cookie auto-rides but the attacker can't read it (SOP) to set the matching header (which also needs a preflight)
  • Favour a custom header over a form field — adds the CORS preflight barrier on top of the read barrier
  • Best for stateless backends, SPAs, and APIs; synchronizer is better for stateful server-rendered apps
  • Pitfall: a subdomain / sibling XSS / non-HTTPS attacker can write the cookie → plant a known value → naive cookie==header is forgeable
  • Fix: the signed / HMAC variant binds the token to the session, so a planted cookie value the server didn't issue fails validation
  • Angular (XSRF-TOKENX-XSRF-TOKEN) and Django ship automated double-submit
  • The CSRF token cookie is intentionally not HttpOnly (JS must read it); the session cookie still should be — and this is safe only absent XSS
  • Next chapter: SameSite cookies — how a browser-level attribute changed the whole CSRF landscape