Double-Submit Cookie
Double-submit cookie pattern: server sets a CSRF token in a cookie AND requires the same value in a request header. Stateless — no server-side session storage required. Cheap, scalable, works for SPAs.
Cookie + header + edge cases
EXAMPLE
// 1) The pattern — three pieces
//
// a. Server generates a random token; sends it as a NON-HttpOnly cookie
// so JavaScript can read it.
// b. Browser stores the cookie; JS reads it on every mutating request and
// sends it as a custom header (e.g. X-CSRF-Token).
// c. Server compares: cookie value === header value? → allow. Mismatch → 403.
//
// Cross-origin attackers CAN forge a cookie (they can't read it across origins, but
// browser sends it automatically). However, they CANNOT set a custom header from a
// cross-site request without a preflight that they can't pass. So the header proves
// the request came from your origin.
// 2) Express middleware (stateless)
import crypto from 'node:crypto';
import cookieParser from 'cookie-parser';
import express from 'express';
const app = express();
app.use(cookieParser());
app.use(express.json());
const CSRF_COOKIE = 'csrf_token';
const CSRF_HEADER = 'x-csrf-token';
app.use((req, res, next) => {
// Set token cookie on first visit
if (!req.cookies[CSRF_COOKIE]) {
const token = crypto.randomBytes(32).toString('base64url');
res.cookie(CSRF_COOKIE, token, {
sameSite: 'lax',
secure: true,
httpOnly: false, // MUST be readable from JS
path: '/',
maxAge: 60 * 60 * 24 * 7 * 1000,
});
req.cookies[CSRF_COOKIE] = token;
}
next();
});
function requireCsrf(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const cookieToken = req.cookies[CSRF_COOKIE];
const headerToken = req.headers[CSRF_HEADER];
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'csrf_missing' });
}
if (!crypto.timingSafeEqual(Buffer.from(cookieToken), Buffer.from(headerToken))) {
return res.status(403).json({ error: 'csrf_mismatch' });
}
next();
}
app.use(requireCsrf);
// Sample protected route
app.post('/api/posts', (req, res) => {
// ... business logic
res.json({ ok: true });
});
// 3) Client (fetch wrapper)
function getCookie(name) {
return document.cookie
.split('; ')
.find(c => c.startsWith(name + '='))
?.split('=')[1];
}
async function api(path, opts = {}) {
const method = (opts.method ?? 'GET').toUpperCase();
const headers = new Headers(opts.headers ?? {});
if (!['GET', 'HEAD', 'OPTIONS'].includes(method)) {
const token = getCookie('csrf_token');
if (token) headers.set('X-CSRF-Token', token);
if (!headers.has('Content-Type') && opts.body) headers.set('Content-Type', 'application/json');
}
return fetch(path, { ...opts, headers, credentials: 'include' });
}
await api('/api/posts', { method: 'POST', body: JSON.stringify({ title: 'Hi' }) });
// 4) Signed double-submit (stronger variant)
// Instead of raw randomness, server signs the token with a server secret.
// Cookie: token + HMAC(token + sessionId)
// Header: same value
// On verify: split, HMAC again, compare in constant-time.
// Prevents an attacker who can write a cookie (subdomain takeover) from forging it.
import crypto from 'node:crypto';
function makeToken(sessionId) {
const token = crypto.randomBytes(32).toString('base64url');
const sig = crypto.createHmac('sha256', SERVER_SECRET).update(token + sessionId).digest('base64url');
return token + '.' + sig;
}
function verifyToken(value, sessionId) {
const [token, sig] = value.split('.');
if (!token || !sig) return false;
const expected = crypto.createHmac('sha256', SERVER_SECRET).update(token + sessionId).digest('base64url');
return crypto.timingSafeEqual(Buffer.from(sig), Buffer.from(expected));
}
// 5) Pair with SameSite — defence in depth
res.cookie('session', sid, {
sameSite: 'lax', // already blocks 99% of CSRF
secure: true,
httpOnly: true,
});
// Double-submit then closes the remaining gaps:
// - Subdomain takeover that bypasses SameSite
// - <link rel=prefetch> hits that send cookies
// - Legacy clients that ignore SameSite
// 6) __Host- cookie prefix — extra hardening
res.cookie('__Host-csrf_token', token, {
sameSite: 'lax',
secure: true,
httpOnly: false,
path: '/',
// No 'domain' attribute — that's required for __Host- prefix
});
// __Host- prefix means: must be Secure, must have path=/, must NOT have Domain.
// A subdomain CANNOT overwrite this cookie. Defense against subdomain takeover.
// 7) Edge cases
// a) AJAX from same origin — works (cookie + JS available)
// b) Mobile apps — same logic; the app reads the cookie or stores token in a vault
// c) Server-side rendering — inject token via meta tag instead of relying on JS cookie read
//
// <meta name="csrf-token" content="{{ csrfToken }}">
// headers['X-CSRF-Token'] = document.querySelector('meta[name="csrf-token"]').content;
// d) GraphQL — all mutations are POST; same middleware works
// e) File uploads — multipart/form-data still requires the X-CSRF-Token header
// 8) Trade-offs vs synchronizer-token (server stores token in session)
// Double-submit | Synchronizer
// ───────────────────────────────────────|──────────────────────
// Stateless — scales horizontally | Stateful (session lookup)
// Token stored in cookie (JS readable) | Token stored server-side
// Cookie spoofing via subdomain possible | Token validation 100% server-side
// Vulnerable to subdomain takeover unless| Subdomain takeover doesn't help attacker
// __Host- prefix or signed |
// Easier to implement for SPAs | Slightly more setup
// 9) Common bugs
// ❌ Setting HttpOnly on the CSRF cookie → JS can't read it; can't send the header
// ❌ Not requiring the header on writes → double-submit becomes useless
// ❌ Comparing strings with === (timing leak) → use timingSafeEqual
// ❌ Same token for the lifetime of an account → easier to leak / phish
// ❌ Not pinning to SameSite=Lax — relying ONLY on double-submit
// ❌ Skipping the check on OPTIONS preflight — that's fine, OPTIONS is read-only
// 10) When NOT to use double-submit
// - Cross-origin embedded widget (cookies need SameSite=None) — token in a header works,
// but combine with origin checks
// - Subdomain-rich orgs where subdomain takeover is realistic — prefer signed variant or
// synchronizer token
// 11) Modern frameworks
// Laravel, Django, Rails, ASP.NET — all ship a CSRF mechanism. Use theirs unless you have
// a specific reason to roll your own.
// 12) Best practices summary
// • SameSite=Lax session cookie
// • __Host- prefix on the CSRF cookie
// • Signed CSRF token (HMAC + server secret + sessionId)
// • Custom header (X-CSRF-Token) on every mutating request
// • Timing-safe comparison
// • Reject OR rotate token on auth change
Why it matters
Stateless and cheap: random cookie + matching header in a custom name forces the browser’s same-origin rules to do the validation. Pair with SameSite=Lax + __Host- prefix and the attack surface shrinks to nothing.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// Server sets a random cookie: Set-Cookie: csrf=abc; SameSite=Strict // Client mirrors it in a header: X-CSRF-Token: abc // Server checks header === cookie. // Works without server-side session storage.Try it Yourself »
Exercise
Custom header for the double-submit pattern.
X-
-Token: …
Four letters.
Discussion
Loading…