Security
CSRF and the Double-Submit Cookie Defense, Step by Step
One forged HTTP POST, fired from a hidden <form> on an attacker's page, can transfer your money, change your email, or delete your account — all while your browser dutifully attaches your session cookie. That is Cross-Site Request Forgery (CSRF), and the double-submit cookie is the classic stateless defense against it.
The double-submit cookie pattern sends a random CSRF token to the browser in two independent channels — a cookie and a request field (form parameter or custom header) — and the server accepts the request only if the two copies match. Because the same-origin policy forbids a cross-site attacker from reading the victim's cookie, the attacker cannot know the token to echo it back, so the forged request fails the equality check. Unlike the synchronizer-token pattern, the server stores nothing: verification is a single comparison.
- TypeStateless CSRF defense (web security)
- Attack defendedCross-Site Request Forgery (CSRF/XSRF)
- Server stateNone — one token equality check per request
- Verification costO(n) constant-time compare (n = token length)
- Key exploitCookie injection via sibling subdomain / DNS takeover
- Recommended formSigned (HMAC) token bound to the session
Interactive visualization
Press play, or step through manually. The visualization is yours to drive — try it before reading on.
Watch the 60-second explainer
A condensed visual walkthrough — narrated, captioned, under a minute.
The problem: your cookies are ambient authority
HTTP is stateless, so browsers re-authenticate every request by automatically attaching stored cookies for the target site — including your logged-in session cookie. This is convenient, but it means the browser proves who you are, not which site asked. A malicious page at evil.example can contain:
<form action="https://bank.example/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="10000">
</form>
<script>document.forms[0].submit()</script>When you visit that page, the browser POSTs to bank.example and cheerfully includes your bank session cookie. The server sees a valid session and executes the transfer. This is CSRF: the attacker never reads any response (the same-origin policy blocks that) — they only need the side effect to fire. The core defect is that a legitimate request and a forged one are byte-for-byte indistinguishable. The fix must add a secret the attacker cannot know or forge: an unpredictable, per-session CSRF token that a cross-site page has no way to obtain.
How double-submit works, step by step
The insight: a cross-site attacker can cause the browser to send your cookies but cannot read them (same-origin policy) and cannot read the DOM of your bank page. So put the token where it must be both readable-by-you and unreadable-by-them.
- Issue: On login (or first page load) the server generates a cryptographically strong random token
tand sets it as a cookie:Set-Cookie: csrf=t. This cookie is notHttpOnly, because the site's own JavaScript must read it. - Reflect: The site's own code copies
tinto every state-changing request — a hidden form field<input name="csrf" value="t">or a headerX-CSRF-Token: t. - Submit: The browser sends two copies of
t: the cookie (automatic) and the body/header (explicit). - Verify: The server checks
request.body.csrf === request.cookie.csrfusing a constant-time compare. Match ⇒ proceed; mismatch or missing ⇒ 403.
A cross-site attacker can trigger the cookie copy but cannot read t to fill the body copy, so the two values disagree and the request is rejected. Crucially, the server keeps no record of t — it just checks the two submitted copies against each other. That statelessness is the whole appeal.
Complexity and a worked trace
Cost. Token generation is O(1) — draw 32 random bytes from a CSPRNG. Per-request verification is a single string comparison, O(n) in the token length n (typically 32–64 bytes), which is negligible. Space is O(1) on the server — no per-user token table, unlike the synchronizer pattern's O(number of active sessions). For the signed variant, verification also recomputes one HMAC: still O(n).
Legitimate request:
Cookie: csrf = 9f3a...e2
Body: csrf = 9f3a...e2
compare 9f3a...e2 == 9f3a...e2 -> 200 OKForged request from evil.example:
Cookie: csrf = 9f3a...e2 (browser attaches it automatically)
Body: csrf = ???? (attacker cannot read the cookie)
compare 9f3a...e2 != guess -> 403 ForbiddenConstant-time compare matters: use crypto.timingSafeEqual / hmac.compare_digest, not ==. A short-circuiting comparison leaks how many leading bytes matched, letting an attacker recover the token one byte at a time via timing. And always require the token on every unsafe method (POST/PUT/PATCH/DELETE), never trusting GET to be side-effect-free.
Where it's used in real systems
Double-submit is the default in much of the web framework ecosystem, precisely because it needs no shared session store — ideal for horizontally-scaled, stateless backends:
- Django uses a masked double-submit:
csrftokencookie plus acsrfmiddlewaretokenform field (orX-CSRFTokenheader), with per-request masking to resist BREACH-style compression attacks. - Angular ships an
HttpXsrfInterceptorthat reads theXSRF-TOKENcookie and echoes it as theX-XSRF-TOKENheader — a canonical double-submit for SPAs. Axios and many API clients follow the sameXSRF-TOKEN/X-XSRF-TOKENconvention. - Spring Security offers
CookieCsrfTokenRepositoryfor exactly this stateless cookie-based flow. - Countless REST/JSON APIs combine a custom CSRF header with a CORS preflight, since a genuinely custom header on a cross-origin request forces a preflight the attacker's non-JS form can't satisfy.
OWASP now recommends the signed double-submit for new code, and pairing any CSRF token with SameSite cookies as defense in depth.
Versus the alternatives, and the tradeoff
Synchronizer Token Pattern (STP) is the older, more robust cousin: the server generates a token, stores it in the session, and compares the submitted token against that stored value. Because the trusted copy lives on the server (not in a cookie), an attacker who can plant a cookie gains nothing. The cost is state: O(sessions) storage and stickiness that complicates scaling. Double-submit trades that robustness for statelessness.
SameSite cookies (Lax/Strict) tell the browser not to attach the cookie on cross-site requests, killing the ambient-authority premise of CSRF at the source. But SameSite is scoped to the registrable domain, not the origin, so a sibling subdomain is treated as same-site; Lax still allows top-level GET navigations; and it depends on browser support. It's a strong layer, not a complete replacement.
The tradeoff in one line: synchronizer = server state, strongest; naive double-submit = zero state, weakest; signed double-submit = zero state plus a server secret, closing the gap. Modern best practice is signed double-submit and SameSite, belt and suspenders.
Pitfalls, failure modes, and the signed fix
The naive pattern has a real, exploitable hole: it assumes an attacker cannot write a cookie on your domain. That assumption breaks under cookie injection:
- A vulnerable or attacker-controlled sibling subdomain (
blog.example) can set a cookie for the parent domain (Domain=.example). - DNS takeover of a dangling subdomain hands an attacker that power.
- Plaintext-HTTP injection: a network attacker can set a cookie over HTTP that overwrites the HTTPS one, unless it's a
__Host-/Securecookie.
If the attacker can set both the cookie and the body to a value they chose, the two match and the check passes — full bypass. Fixes: (1) use a __Host- prefixed cookie so it's origin-locked, Secure, path /, and un-scopable to subdomains; (2) adopt the signed double-submit: the token is HMAC(secret, sessionID ∥ random) ∥ random. The server recomputes the HMAC from the session and rejects any token it didn't sign — so an injected cookie with an attacker-chosen value can never verify. Other traps: forgetting the token on AJAX, using a non-constant-time compare, marking the CSRF cookie HttpOnly (then JS can't read it), and protecting GET but leaking side effects through it.
| Defense | Server state | Stops cookie injection? | Key tradeoff / weakness |
|---|---|---|---|
| Synchronizer token | Yes — token stored per session | Yes (token never lives only in a cookie) | Needs session storage; harder to scale statelessly |
| Naive double-submit | No | No — attacker who can write a cookie on the domain wins | Simple & stateless, but bypassable via sibling subdomain |
| Signed double-submit (HMAC) | No (just a server secret) | Yes — injected cookie can't produce a valid HMAC | Requires a server secret and session binding |
| SameSite=Lax/Strict cookie | No | Partial — domain-level, not origin-level | Registrable-domain scope; GET/top-nav gaps; UA support |
| Custom-header check (SPA) | No | N/A (relies on CORS preflight) | Only works for JS/fetch clients, not HTML forms |
Frequently asked questions
Why does the CSRF cookie need to be readable by JavaScript (not HttpOnly)?
Because the pattern requires your own site's JavaScript to copy the token from the cookie into a request header or form field. If the cookie were HttpOnly, JS couldn't read it and couldn't reflect it, breaking the 'double submit.' This is safe as long as you don't have XSS — and if you do have XSS, no CSRF defense holds anyway. The session cookie itself should still be HttpOnly; only the CSRF token cookie is readable.
If the browser attaches the cookie automatically to forged requests too, how does this stop anything?
The attacker can make the browser send the cookie, but they cannot read its value from a cross-site context (same-origin policy) or read your page's DOM. So they cannot fill in the matching body/header copy. The cookie copy will be present but the explicit copy will be wrong or missing, and the server's equality check fails.
What exactly is the weakness of the naive double-submit pattern?
It trusts that only your site can set the CSRF cookie. If an attacker can write a cookie on your domain — via a vulnerable sibling subdomain, a DNS takeover, or plaintext-HTTP injection on a non-__Host- cookie — they can set both the cookie and the body to a value they know, and the two will match. The fix is a __Host- cookie plus HMAC-signing the token to the session.
How is this different from the synchronizer token pattern?
Synchronizer tokens store the authoritative token server-side in the session and compare the submitted token against that stored value, so a cookie an attacker plants is irrelevant — but it requires O(sessions) server state. Double-submit stores nothing server-side and just compares the cookie copy to the body copy, trading robustness for statelessness. Signed double-submit narrows that robustness gap using only a single server secret.
Doesn't SameSite=Strict make CSRF tokens unnecessary?
SameSite helps a lot but isn't complete. It's scoped to the registrable domain, so sibling subdomains count as same-site; Lax still permits top-level GET navigations; and you can't assume universal, correct browser enforcement. OWASP recommends keeping a CSRF token as the primary defense and using SameSite as a strong second layer, not a replacement.
Why must the token comparison be constant-time?
A naive == or strcmp short-circuits at the first differing byte, so the time it takes reveals how many leading bytes matched. An attacker could exploit that timing to reconstruct the token byte-by-byte. Use a constant-time comparator like crypto.timingSafeEqual, hmac.compare_digest, or MessageDigest.isEqual so the comparison time is independent of where the mismatch occurs.