Skip to content
CWE-352A01:2021 – Broken Access Control

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

JavaScriptcsrf.jsVulnerable
// 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 SameSite is no obstacle at all.
  • Anyone who can MITM plaintext HTTP on any subdomain, because Secure governs 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

JavaScriptcsrf.jsSecure
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

PatternServer stateSurvives a subdomain attacker?Verdict
Synchronizer tokenYes — token stored per sessionYes — the attacker still cannot read the target's HTMLThe default recommendation. Use your framework's.
Naive double-submitNoNo — an attacker who writes the cookie satisfies the checkDiscouraged by OWASP. Do not build new systems on it.
Signed (HMAC) double-submitNo — only a server-side secretYes — an overwritten cookie cannot carry a valid MACThe right stateless choice. Bind the session id into the signature.
Encrypted tokenNo — only a server-side keyYes — same reasoning, and the payload is opaqueEquivalent in strength; useful when you want to carry a timestamp or user id inside the token.

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 forbids Domain, 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 HttpOnly in 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 with XorCsrfTokenRequestAttributeHandler and an Origin check. See Spring Security.