Implementing the Double Submit Cookie Pattern in Node.js
CSRF exploits the browser’s automatic credential attachment — cookies and HTTP Basic auth headers are sent with every cross-origin request — to trick a server into accepting commands the user never issued. The double submit cookie pattern neutralises this without any server-side token store: the server issues a random value as a JavaScript-readable cookie, and every mutating request must echo that exact value in a custom header. Because the browser’s Same-Origin Policy prevents cross-origin scripts from reading cookies on another domain, an attacker cannot forge the matching header. This guide is a deep-dive under the Cross-Site Request Forgery (CSRF) Defense cluster, itself part of Vulnerability Patterns & Web Mitigation Strategies. For pages where injection attack prevention or XSS mitigation is also in scope, apply those controls in parallel — they address different threat classes at the same request boundary.
Prerequisites
- Node.js 18 or later (native
cryptomodule,node:cryptoimport) - Express 4.x with
cookie-parsermiddleware installed - HTTPS enforced in all environments (the
Securecookie flag requires TLS) - A single registered origin or a known allow-list of origins (wildcard CORS defeats this control)
- Familiarity with the STRIDE framework is helpful for understanding why spoofing and cross-site request threats sit in different STRIDE categories
Expected Outcomes
- Every
POST,PUT,PATCH, andDELETErequest is rejected with HTTP 403 unless theXSRF-TOKENcookie andX-CSRF-Tokenheader match. - Token generation uses
crypto.randomBytes(32)— 256 bits of entropy. - A Playwright security test suite catches regressions in CI before they reach staging.
- Audit logs capture every validation failure with IP, endpoint, and timestamp for SOC 2 evidence.
Step 1: Token Generation and Cookie Issuance
The token must be generated with a cryptographically secure RNG — Math.random() is not acceptable. The cookie carrying it must be readable by JavaScript (httpOnly: false) so the client can copy it into a request header. This is the intentional asymmetry that separates the CSRF cookie from the session cookie, which must remain httpOnly: true.
// src/security/csrf-token.js
import crypto from 'node:crypto';
/**
* Returns a URL-safe 32-byte hex token with 256 bits of entropy.
* Never use Math.random() — it is not cryptographically secure.
*/
export function generateCsrfToken() {
return crypto.randomBytes(32).toString('hex');
}
Issue the token on every successful authentication response and on every GET that initialises a session-bearing page. Rotate it on each successful state-changing request (or on a fixed time window — see the concurrency note in Step 3).
// src/middleware/csrf-issue.js
import { generateCsrfToken } from '../security/csrf-token.js';
/**
* Attaches a fresh XSRF-TOKEN cookie after login / session creation.
* Call this after session establishment, not on every request.
*/
export function issueCsrfToken(req, res, next) {
const token = generateCsrfToken();
res.cookie('XSRF-TOKEN', token, {
httpOnly: false, // REQUIRED: client JS must be able to read this value
secure: true, // HTTPS only — never weaken this in any environment
sameSite: 'strict', // Primary browser-level CSRF barrier
path: '/',
maxAge: 1_800_000, // 30-minute TTL in milliseconds
domain: process.env.COOKIE_DOMAIN || undefined
});
// Expose the token in a response header for SPA bootstrapping
res.setHeader('X-New-CSRF-Token', token);
next();
}
The diagram below shows the issuance and validation flow across a browser, CDN edge, and an Express API:
Step 2: Express Validation Middleware
The middleware performs four checks in order: skip safe methods, assert token presence, assert length equality (prevents allocation-size oracle leaks), then perform a timing-safe byte comparison. crypto.timingSafeEqual eliminates timing side-channels that could let an attacker brute-force the token byte by byte.
// src/middleware/csrf-validator.js
import crypto from 'node:crypto';
import { generateCsrfToken } from '../security/csrf-token.js';
import { logCsrfEvent } from '../audit/csrf-logger.js';
export function csrfValidator(req, res, next) {
// Safe methods carry no state-changing semantics — skip validation
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const cookieToken = req.cookies['XSRF-TOKEN'];
const headerToken = req.headers['x-csrf-token'] ?? req.headers['x-xsrf-token'];
if (!cookieToken || !headerToken) {
logCsrfEvent('TOKEN_MISSING', req, { missing: !cookieToken ? 'cookie' : 'header' });
return res.status(403).json({
error: 'CSRF_TOKEN_MISSING',
message: 'Anti-CSRF token absent from cookie or request header.'
});
}
const cookieBuf = Buffer.from(String(cookieToken), 'utf8');
const headerBuf = Buffer.from(String(headerToken), 'utf8');
// Length mismatch guard — timingSafeEqual throws if buffers differ in length
if (cookieBuf.length !== headerBuf.length || !crypto.timingSafeEqual(cookieBuf, headerBuf)) {
logCsrfEvent('TOKEN_MISMATCH', req, { endpoint: req.originalUrl });
return res.status(403).json({
error: 'CSRF_TOKEN_MISMATCH',
message: 'Anti-CSRF token validation failed.'
});
}
// Rotate the token after a successful mutating request
const nextToken = generateCsrfToken();
res.cookie('XSRF-TOKEN', nextToken, {
httpOnly: false,
secure: true,
sameSite: 'strict',
path: '/',
maxAge: 1_800_000,
domain: process.env.COOKIE_DOMAIN || undefined
});
res.setHeader('X-New-CSRF-Token', nextToken);
next();
}
Wire the middleware into your Express app after cookie-parser, and before any route that handles state-changing operations:
// src/app.js
import express from 'express';
import cookieParser from 'cookie-parser';
import { csrfValidator } from './middleware/csrf-validator.js';
const app = express();
app.use(express.json());
app.use(cookieParser());
app.use(csrfValidator); // applies globally; exclude paths below if needed
// Example: API routes that require CSRF protection
app.post('/api/transfer', (req, res) => res.json({ status: 'ok' }));
app.put('/api/profile', (req, res) => res.json({ status: 'ok' }));
Step 3: Frontend Fetch Interceptor and Concurrency Handling
Parallel AJAX requests create a race condition when tokens rotate per request. Request A and Request B both read Token X. Request A completes first and the server rotates to Token Y. Request B then arrives with Token X — which is now stale — and gets rejected, causing a false-positive 403.
The safest resolution for most SPAs: rotate on a fixed time window (30 minutes) rather than on every single request. This eliminates the race without reducing security materially, because the attack window for a stolen token is bounded by the window expiry, not by request count.
The fetch interceptor below reads the current token from the cookie on initialisation, attaches it to every mutating request, and updates its in-memory reference from the X-New-CSRF-Token response header:
// src/client/csrf-interceptor.js
let currentToken = null;
function readCsrfCookie() {
const match = document.cookie.match(/(?:^|;\s*)XSRF-TOKEN=([^;]+)/);
return match ? decodeURIComponent(match[1]) : null;
}
export function setupCsrfInterceptor() {
currentToken = readCsrfCookie();
const originalFetch = window.fetch;
window.fetch = async (url, options = {}) => {
const method = (options.method || 'GET').toUpperCase();
const isMutating = !['GET', 'HEAD', 'OPTIONS'].includes(method);
if (isMutating && currentToken) {
options.headers = { ...options.headers, 'X-CSRF-Token': currentToken };
}
const response = await originalFetch(url, options);
// Synchronise the in-memory token with whatever the server just rotated to
const rotated = response.headers.get('X-New-CSRF-Token');
if (rotated) {
currentToken = rotated;
}
return response;
};
}
Mobile webview and proxy edge cases:
- Corporate TLS-inspection proxies sometimes strip custom headers. For affected legacy endpoints, a fallback to a hidden form field (
_csrf) is acceptable provided you also validate against the cookie value — never accept the field value alone. - Mobile webviews that block third-party cookies require the API origin to exactly match the page origin. Confirm this with
document.cookieinspection during QA before releasing to embedded webview targets.
Step 4: Verification and CI/CD Security Gate
A Playwright suite that simulates missing and mismatched tokens prevents CSRF regressions from reaching production. Run it in every pull request pipeline against a test instance.
// tests/security/csrf-validation.spec.js
import { test, expect } from '@playwright/test';
const API = process.env.TEST_API_BASE ?? 'http://localhost:3000';
test.describe('CSRF middleware — double submit cookie validation', () => {
test('POST without X-CSRF-Token header returns 403 CSRF_TOKEN_MISSING', async ({ request }) => {
// Bootstrap a valid cookie first
const init = await request.get(`${API}/api/session-init`);
const cookies = await init.cookies();
const csrfCookie = cookies.find(c => c.name === 'XSRF-TOKEN');
expect(csrfCookie).toBeTruthy();
const res = await request.post(`${API}/api/transfer`, {
headers: {
'Cookie': `XSRF-TOKEN=${csrfCookie.value}`,
'Content-Type': 'application/json'
// deliberately omit X-CSRF-Token
},
data: { amount: 100, to: 'attacker' }
});
expect(res.status()).toBe(403);
expect((await res.json()).error).toBe('CSRF_TOKEN_MISSING');
});
test('POST with mismatched token returns 403 CSRF_TOKEN_MISMATCH', async ({ request }) => {
const res = await request.post(`${API}/api/transfer`, {
headers: {
'Cookie': 'XSRF-TOKEN=legitimate-server-token',
'X-CSRF-Token': 'attacker-forged-token',
'Content-Type': 'application/json'
},
data: { amount: 500, to: 'attacker' }
});
expect(res.status()).toBe(403);
expect((await res.json()).error).toBe('CSRF_TOKEN_MISMATCH');
});
test('POST with matching token succeeds and response includes X-New-CSRF-Token', async ({ request }) => {
const init = await request.get(`${API}/api/session-init`);
const cookies = await init.cookies();
const { value: token } = cookies.find(c => c.name === 'XSRF-TOKEN');
const res = await request.post(`${API}/api/transfer`, {
headers: {
'Cookie': `XSRF-TOKEN=${token}`,
'X-CSRF-Token': token,
'Content-Type': 'application/json'
},
data: { amount: 10, to: 'recipient' }
});
expect(res.status()).toBe(200);
expect(res.headers()['x-new-csrf-token']).toBeTruthy();
});
});
Add a CI gate in your pipeline YAML that fails the build if any CSRF test is skipped or errored:
# .github/workflows/security.yml
name: Security gates
on: [push, pull_request]
jobs:
csrf-validation:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with: { node-version: '20' }
- run: npm ci
- run: npm run start:test &
env:
NODE_ENV: test
COOKIE_DOMAIN: localhost
- run: npx playwright install chromium --with-deps
- run: npx playwright test tests/security/csrf-validation.spec.js
env:
TEST_API_BASE: http://localhost:3000
Verification
After deploying the middleware, confirm the control is active using curl:
# Should return 403 — header absent
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://your-app.example.com/api/transfer \
-H "Cookie: XSRF-TOKEN=testtoken" \
-H "Content-Type: application/json" \
-d '{"amount":1}'
# Expected: 403
# Should return 403 — header does not match cookie
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://your-app.example.com/api/transfer \
-H "Cookie: XSRF-TOKEN=testtoken" \
-H "X-CSRF-Token: wrongtoken" \
-H "Content-Type: application/json" \
-d '{"amount":1}'
# Expected: 403
# Should return 200 — header matches cookie
curl -s -o /dev/null -w "%{http_code}" \
-X POST https://your-app.example.com/api/transfer \
-H "Cookie: XSRF-TOKEN=testtoken" \
-H "X-CSRF-Token: testtoken" \
-H "Content-Type: application/json" \
-d '{"amount":1}'
# Expected: 200
Confirm structured audit logs are emitting on failures:
tail -f logs/csrf-audit.log | grep TOKEN_MISMATCH
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| All POST requests return 403 even with correct headers | cookie-parser middleware not mounted before csrfValidator |
Verify app.use(cookieParser()) appears before app.use(csrfValidator) in app.js |
| SPA users get 403 after clicking back/forward rapidly | Per-request rotation racing with parallel requests | Switch to time-window rotation (30-minute maxAge); avoid rotating on every request |
timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH |
Cookie and header buffers differ in byte length | The cookieBuf.length !== headerBuf.length guard must fire before the timingSafeEqual call — check that guard is present |
Token not appearing in X-New-CSRF-Token response header |
res.setHeader called after res.json() flushes headers |
Move res.setHeader('X-New-CSRF-Token', ...) before any res.json() or next() call |
| Mobile users intermittently fail CSRF checks | Webview strips custom headers via an intermediary proxy | Add hidden form-field fallback (_csrf) validated against the cookie; log User-Agent on failures to confirm proxy involvement |
Compliance Mapping
| Framework | Control | How the double submit pattern satisfies it |
|---|---|---|
| OWASP ASVS V5.1.1 | Verify that state-changing requests use unpredictable tokens | crypto.randomBytes(32) produces 256-bit entropy; token is verified on every mutating request |
| OWASP ASVS V5.2.1 | Verify anti-CSRF tokens are not transmitted over unencrypted connections | secure: true cookie flag prevents transmission over HTTP |
| SOC 2 CC6.1 | Logical access controls enforce request integrity | Middleware rejects any mutating request that cannot prove same-origin knowledge |
| SOC 2 CC6.6 | System boundaries protected against unauthorised access | The token binding ensures cross-origin requests cannot succeed without cookie read access |
| NIST SP 800-53 SC-8 | Transmission confidentiality and integrity | secure: true and sameSite: 'strict' combine with HTTPS enforcement |
The structured audit logger captures evidence for SOC 2 CC7.2 (monitoring of system components):
// src/audit/csrf-logger.js
import winston from 'winston';
const log = winston.createLogger({
level: 'info',
format: winston.format.combine(
winston.format.timestamp(),
winston.format.json()
),
transports: [
new winston.transports.File({ filename: 'logs/csrf-audit.log' })
]
});
export function logCsrfEvent(eventType, req, details = {}) {
log.info({
event: eventType, // 'TOKEN_MISSING' | 'TOKEN_MISMATCH' | 'TOKEN_ISSUED'
ip: req.ip,
userAgent: req.headers['user-agent'],
endpoint: req.originalUrl,
method: req.method,
...details
});
}
Common Pitfalls
Frequently Asked Questions
Is the double submit cookie pattern secure without SameSite attributes?
No. SameSite=Lax or SameSite=Strict is the primary browser-level CSRF barrier. The double submit mechanism is defense-in-depth for older browsers, unusual configurations, and attack scenarios where SameSite cookies are bypassed via subdomain injection. Always set both.
Can the double submit cookie pattern be bypassed via XSS?
Yes. If an attacker can execute JavaScript in the victim’s browsing context for your origin — through a stored or reflected XSS vulnerability — they can call document.cookie to read the XSRF-TOKEN value and inject it as a header. CSRF protection is not a substitute for DOM-based vulnerability sanitisation or a strict Content Security Policy. Fix XSS first.
How does this pattern work across microservices without shared state?
It is entirely stateless. Each service receives the same two pieces of data — the cookie value and the header value — and compares them locally using timingSafeEqual. No session store, Redis lookup, or inter-service RPC is needed. Consistency across services is maintained by sharing the same middleware module and enforcing identical cookie attribute requirements (sameSite, secure, minimum token length) via a shared package.
What token rotation strategy avoids race conditions in SPAs?
Prefer a fixed time window (15–30 minutes) over per-request rotation. The server sets a maxAge matching the window and only issues a new token when the old one is within 60 seconds of expiry or after an explicit session event (login, privilege escalation). The response always includes the current token in X-New-CSRF-Token; the client interceptor updates its in-memory reference and never needs to re-read the cookie.
Related
- Cross-Site Request Forgery (CSRF) Defense — parent cluster covering the full threat anatomy, mitigation architecture, and compliance mapping for CSRF
- Vulnerability Patterns & Web Mitigation Strategies — the broader security engineering context this guide operates within
- Cross-Site Scripting (XSS) Mitigation — XSS and CSRF share an origin trust boundary; fix XSS before relying on CSRF tokens
- Escaping User Input in React and Vue Components — practical XSS prevention for the same SPA context where this CSRF pattern is typically deployed
- DOM-Based Vulnerability Sanitisation — addresses the DOM attack vectors that can undermine cookie-based CSRF controls