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 crypto module, node:crypto import)
  • Express 4.x with cookie-parser middleware installed
  • HTTPS enforced in all environments (the Secure cookie 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, and DELETE request is rejected with HTTP 403 unless the XSRF-TOKEN cookie and X-CSRF-Token header 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.

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:

Double Submit Cookie Request Flow Sequence diagram showing the browser receiving an XSRF-TOKEN cookie on login, then echoing that value in the X-CSRF-Token header on a POST request, which the Express middleware validates by comparing cookie to header. Browser / SPA CDN / Edge Express API GET /login → credentials 200 OK + Set-Cookie: XSRF-TOKEN=abc… (httpOnly=false) JS reads cookie value POST /api/transfer Cookie: XSRF-TOKEN=abc… X-CSRF-Token: abc… timingSafeEqual( cookie, header) 200 OK + X-New-CSRF-Token: xyz… Update in-memory token POST without header → 403 CSRF_TOKEN_MISSING

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.cookie inspection 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

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.

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.