Double-Submit Cookies
Naive double-submit and why OWASP now discourages it, then the HMAC-signed and encrypted-token variants that fix it.
The stateless alternative
Double-submit exists because the synchronizer token needs server-side state: somewhere to store the expected value per session. For a horizontally-scaled or genuinely stateless service that is an annoyance.
The idea: send the token twice — once in a cookie, once in a form field or header — and have the server check that the two match. The server stores nothing. It works because of an asymmetry: an attacker's page can cause the cookie to be sent (that is CSRF) but cannot read it to put the same value in the body.
That asymmetry is real. The problem is the other one people assume: that an attacker cannot write the cookie either. They often can — and that is what breaks the naive form of this pattern.
OWASP now explicitly discourages naive double-submit and recommends the signed variant instead. If you are implementing this today, skip to the signed version.
Naive double-submit, and how it fails
// Issue a random value as a readable cookie.
function issueToken(res) {
const token = crypto.randomBytes(32).toString('hex')
// httpOnly must be false so the page's own JS can copy it into the header.
// Domain is set so the API subdomain also receives it -- and this is
// precisely the line that makes the whole thing collapse.
res.cookie('csrf', token, { httpOnly: false, domain: '.example.com' })
return token
}
// Verify the two copies match.
function verify(req, res, next) {
const fromCookie = req.cookies.csrf
const fromHeader = req.get('X-CSRF-Token')
// The check is "do these two attacker-supplied values agree with each
// other", NOT "is this value one the server issued to this session".
// Nothing here ties the token to the session at all.
if (!fromCookie || fromCookie !== fromHeader) {
return res.status(403).json({ error: 'CSRF' })
}
next()
}Why the failure is structural
The naive pattern verifies internal consistency, not provenance. It answers "do these two values match?" when the question that matters is "did I issue this value to this session?"
Any attacker who can write a cookie into the target's scope can satisfy internal consistency trivially. And cookie scope is domain-based, not origin-based, so the set of parties who can write one is much larger than the set who can read the target's HTML:
- Every sibling subdomain — and subdomains are same-site, so
SameSiteis no obstacle at all. - Anyone who can MITM plaintext HTTP on any subdomain, because
Securegoverns sending, not overwriting. - Any response-splitting or CRLF-injection issue anywhere in the domain.
This is the cookie-tossing attack, and it is why the mechanism cannot be repaired by adding entropy or rotating faster. The fix must make the token unforgeable, so that an attacker-chosen value fails even when it is internally consistent.
Signed double-submit
const crypto = require('crypto')
const SECRET = process.env.CSRF_SECRET // 32+ random bytes, server-side only
// The token is a random value plus an HMAC over (session id, random value).
// Binding the session id into the signature is the part that matters: a token
// minted for the attacker's own session will not verify against the victim's,
// so simply logging in and reusing your own token does not work either.
function issueToken(res, sessionId) {
const nonce = crypto.randomBytes(32).toString('hex')
const mac = crypto.createHmac('sha256', SECRET)
.update(`${sessionId}!${nonce}`)
.digest('hex')
const token = `${nonce}.${mac}`
res.cookie('__Host-csrf', token, {
// __Host- forbids a Domain attribute, so the cookie is host-only and a
// sibling subdomain cannot overwrite it. Belt to the signature's braces.
path: '/', secure: true, httpOnly: false, sameSite: 'lax',
})
return token
}
function verify(req, res, next) {
const submitted = req.get('X-CSRF-Token') || req.body?.csrf || ''
const [nonce, mac] = submitted.split('.')
if (!nonce || !mac) return res.status(403).json({ error: 'CSRF' })
const expected = crypto.createHmac('sha256', SECRET)
.update(`${req.session.id}!${nonce}`)
.digest('hex')
// Constant-time, and length-checked first because timingSafeEqual throws
// on a length mismatch -- which would itself be an oracle.
const a = Buffer.from(mac), b = Buffer.from(expected)
if (a.length !== b.length || !crypto.timingSafeEqual(a, b)) {
return res.status(403).json({ error: 'CSRF' })
}
// Note we never compared against the COOKIE. The signature is what proves
// provenance; the cookie is just transport. An attacker who overwrites the
// cookie has achieved nothing, because they cannot produce a valid MAC.
next()
}The variants compared
Checklist
- Do not ship naive double-submit. If you have it, the upgrade to signed is small and does not change the client contract.
- Bind the session identifier into the signature. Without it, an attacker logs in, collects a valid token, and replays it in the victim's request — internally consistent and correctly signed.
- Use a
__Host-prefixed cookie. It forbidsDomain, so a sibling subdomain cannot overwrite it. Cheap, and it closes the cookie-writing vector independently of the signature. - Constant-time comparison, with a length check before any comparison that throws on mismatch.
- The cookie cannot be
HttpOnlyin this pattern, since the page's own script must read it. That is an accepted trade-off — but it means XSS trivially defeats it, as XSS defeats every CSRF defence. - Keep the secret out of the repo and rotate it like any other key. Rotating invalidates outstanding tokens, so support two keys during a rollover.
- Spring Security's
withHttpOnlyFalse()cookie repository is naive double-submit; pair it withXorCsrfTokenRequestAttributeHandlerand an Origin check. See Spring Security.
Related
A server-side token bound to the session, plus the lifecycle questions: per-request versus per-session, rotation, BREACH masking, and back-button breakage.
Overwriting the CSRF cookie from a sibling subdomain, why SameSite offers zero protection here, and why __Host- is the fix.
Token not checked, checked only when present, empty accepted, not tied to the session, reused across users, or predictable.
Cookies are scoped by domain, not origin: who can set one, who receives one, and what __Host-, Secure, Path, and Partitioned actually change.
Sec-Fetch-Site, Mode and Dest, Origin checks done correctly, the missing-header decision, and Resource Isolation Policy.
Why .csrf().disable() is the most-copied line in Java web security, CookieCsrfTokenRepository, and the missing Origin check.
csurf is deprecated and archived, csrf-csrf as the replacement, and the body-parser settings that reopen the text/plain vector.