iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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: …

Test yourself

Q1. Double-submit cookie compares…
Q2. Its main advantage over the synchronizer pattern is…
Q3. It should still use SameSite cookies because…

Discussion

Loading…