Cross-Site Request Forgery (CSRF) Defense

Cross-Site Request Forgery exploits the implicit trust that web servers place in browser-attached session cookies, enabling an attacker to force an authenticated user to execute unintended state-changing actions — fund transfers, email changes, account deletions — without any credential theft. A single successful CSRF attack on an admin account can result in full application compromise. This page is part of Vulnerability Patterns & Web Mitigation Strategies, and it covers the full defense stack: token generation, middleware architecture, SameSite policy, bypass patterns, and CI enforcement. For the client-side DOM risks that pair with CSRF in layered attacks, see DOM-Based Vulnerability Sanitization and Cross-Site Scripting (XSS) Mitigation.

Threat Anatomy

The CSRF attack lifecycle requires three conditions: the victim holds an active authenticated session, the application uses cookies (or HTTP auth headers) for that session, and the attacker can lure the victim’s browser to issue a cross-origin request. No credential theft is necessary — the browser does the work.

MITRE ATT&CK reference: T1189 (Drive-by Compromise) is the delivery vector; the CSRF payload itself maps to T1499.002 (Service Exhaustion) and credential-abuse primitives when the forced action modifies authentication state.

The attack flow, step by step from the attacker’s perspective:

  1. Victim authenticates to app.example.com; the browser stores session_id=abc123.
  2. Attacker hosts evil.example.net with an auto-submitting form or fetch() call targeting app.example.com/api/transfer.
  3. Victim visits evil.example.net; the browser issues the POST and automatically appends session_id=abc123 from its cookie jar.
  4. The server sees a valid session cookie and processes the transfer as legitimate.

CSRF differs fundamentally from Cross-Site Scripting (XSS) Mitigation: XSS injects code that executes in the victim’s browser, whereas CSRF forges a request the browser issues. A Content Security Policy that blocks inline scripts does nothing to stop a CSRF attack — the request carries no injected script.

Attack surfaces extend beyond HTML forms. For an in-depth look at how CSRF tokens protect stateless Node.js services specifically, see Implementing Double-Submit Cookie Pattern in Node.js.

Attack Vector Mechanism Severity
HTML form auto-submit <form action="…" method="POST"> with <img src=""> trigger High — no user interaction after page load
Fetch with credentials: 'include' Cross-origin fetch() carrying session cookie High — bypasses simple-request heuristics
WebSocket upgrade hijack Initial HTTP handshake inherits session cookies High — socket established before app logic runs
GET-based state mutations <img src="/api/delete?id=1"> renders silently Medium — depends on non-idempotent GET handlers
Sub-resource requests (fonts, CSS) Browser-initiated cross-origin fetches with credentials Low-medium — limited to GET, but exploitable with unsafe endpoints

Prerequisites & Scope

Apply these controls when:

  • The application authenticates users via browser-stored cookies or HTTP Basic/Digest auth.
  • Any endpoint performs state-changing operations (account mutations, financial transactions, configuration writes).
  • The application serves both a traditional server-rendered UI and a JavaScript SPA or mobile API on the same session cookie domain.

These controls are not required (but remain best practice) when:

  • Every authenticated endpoint uses JWT in the Authorization: Bearer header with no cookie fallback.
  • The entire API is consumed only by server-to-server calls that do not run in a browser context.

Runtime dependencies: Node.js ≥18 / Python ≥3.10 for the examples below; any session middleware that persists tokens across requests (Express express-session, Django sessions, Spring Security session store).

Mitigation Architecture

The following diagram shows the data flow for both the synchronizer token pattern (server-rendered) and the double-submit cookie pattern (SPA/API), illustrating where token generation, transmission, and validation occur.

CSRF Defense Architecture Two-lane diagram. Left lane shows synchronizer token pattern: browser requests page, server generates token bound to session and embeds it in HTML form, browser submits form with token, server validates token against session store. Right lane shows double-submit cookie: server sets CSRF cookie, SPA reads cookie value, SPA sends value in X-CSRF-Token header, server compares cookie vs header. Synchronizer Token Pattern (server-rendered / stateful) Double-Submit Cookie Pattern (SPA / stateless API) Browser Server + Session GET /page HTML + hidden _csrf token store token in session POST + _csrf=TOKEN + cookie Compare token vs session store 200 OK — or 403 Forbidden Attacker forges POST without token Server rejects — token absent or mismatched Rotate token on login / logout / password change / privilege escalation SPA / JS API Server Set-Cookie: csrf_token=XYZ; SameSite=Lax read csrf_token from document.cookie POST + X-CSRF-Token: XYZ + cookie Compare header vs cookie value 200 OK — or 403 Forbidden Attacker on evil.example.net Cannot read SameSite cookie (cross-origin) Cannot forge X-CSRF-Token header with correct value Prefer HMAC-signed tokens over raw random values to survive token theft via sub-domain attacks

The comparison table below maps each defense pattern to its trade-offs:

Pattern State Required Best Suited For Token Theft Resistance
Synchronizer Token (STP) Yes — session store Server-rendered MPA High — token never leaves server
Double-Submit Cookie No Stateless API / SPA Medium — sub-domain theft possible without HMAC
HMAC-Signed Double-Submit No Stateless API with sub-domain isolation concerns High — signature binds token to session ID
Custom Header (X-Requested-With) No CORS-controlled SPA Low-medium — depends on strict CORS enforcement
SameSite=Strict cookie only No Low-complexity internal tools Low — browser support gaps, no cryptographic proof

Step-by-Step Implementation

Step 1 — Generate a Cryptographically Random Token (OWASP ASVS V3.4.1)

Use a CSPRNG. Never derive CSRF tokens from predictable values (timestamps, user IDs, sequential counters).

import { randomBytes } from 'crypto';

/** Returns a hex-encoded 256-bit CSRF token. */
export function generateCsrfToken(): string {
  return randomBytes(32).toString('hex'); // 256 bits — well above the 128-bit minimum
}

For HMAC-signed double-submit (binds token to session, resists sub-domain theft):

import { createHmac, randomBytes, timingSafeEqual } from 'crypto';

const HMAC_SECRET = process.env.CSRF_HMAC_SECRET!; // ≥256-bit secret, rotated per deployment

export function signCsrfToken(sessionId: string): string {
  const nonce = randomBytes(16).toString('hex');
  const mac = createHmac('sha256', HMAC_SECRET)
    .update(`${sessionId}:${nonce}`)
    .digest('hex');
  return `${nonce}.${mac}`;
}

export function verifyCsrfToken(token: string, sessionId: string): boolean {
  const [nonce, mac] = token.split('.');
  if (!nonce || !mac) return false;
  const expected = createHmac('sha256', HMAC_SECRET)
    .update(`${sessionId}:${nonce}`)
    .digest('hex');
  return timingSafeEqual(Buffer.from(mac, 'hex'), Buffer.from(expected, 'hex'));
}

Use timingSafeEqual — not === — to prevent timing-oracle attacks that leak token validity through response latency differences.

Server-rendered (Express + Nunjucks):

import express from 'express';
import session from 'express-session';
import { generateCsrfToken } from './csrf';

const app = express();

app.use(session({ secret: process.env.SESSION_SECRET!, resave: false, saveUninitialized: false }));

app.use((req, res, next) => {
  if (!req.session.csrfToken) {
    req.session.csrfToken = generateCsrfToken();
  }
  res.locals.csrfToken = req.session.csrfToken; // available in templates as {{ csrfToken }}
  next();
});

Template injection (Nunjucks / Jinja2 / any server-side renderer):

<form method="POST" action="/api/update-profile">
  <input type="hidden" name="_csrf" value="{{ csrfToken }}">
  <!-- other fields -->
  <button type="submit">Save</button>
</form>

Cookie flags — set on the session and the CSRF cookie:

Set-Cookie: session_id=abc123; Path=/; Secure; HttpOnly; SameSite=Lax; Max-Age=3600
Set-Cookie: csrf_token=xyz789; Path=/; Secure; SameSite=Lax; Max-Age=3600

SameSite=Lax is the baseline: it permits top-level navigations (e.g. OAuth redirects back to your app) while blocking cross-site POST submissions. Use SameSite=Strict only when you have confirmed that no legitimate third-party redirects need to carry the session cookie.

Step 3 — Deploy Centralized Validation Middleware (OWASP ASVS V5.1.1)

Centralise validation so no endpoint can accidentally skip it.

import { Request, Response, NextFunction } from 'express';
import { timingSafeEqual } from 'crypto';

export function csrfMiddleware(req: Request, res: Response, next: NextFunction): void {
  const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS', 'TRACE']);
  if (SAFE_METHODS.has(req.method)) { next(); return; }

  const sessionToken: string | undefined = req.session.csrfToken;
  const submittedToken: string | undefined =
    (req.headers['x-csrf-token'] as string) || req.body?._csrf;

  if (
    !sessionToken ||
    !submittedToken ||
    sessionToken.length !== submittedToken.length ||
    !timingSafeEqual(Buffer.from(sessionToken), Buffer.from(submittedToken))
  ) {
    res.status(403).json({ error: 'CSRF validation failed', code: 'CSRF_TOKEN_INVALID' });
    return;
  }
  next();
}

// Mount globally — before any state-changing route handlers
app.use(express.json());
app.use(csrfMiddleware);

The frontend reads the cookie value and mirrors it in the X-CSRF-Token request header. The server compares header to cookie without a session lookup — suitable for stateless microservices.

// frontend/csrf.ts
export function getCsrfCookie(): string {
  const match = document.cookie.match(/(?:^|;\s*)csrf_token=([^;]+)/);
  if (!match) throw new Error('CSRF cookie not found — reload the page.');
  return decodeURIComponent(match[1]);
}

export async function secureFetch(url: string, options: RequestInit = {}): Promise<Response> {
  return fetch(url, {
    ...options,
    credentials: 'include',
    headers: {
      'Content-Type': 'application/json',
      ...options.headers,
      'X-CSRF-Token': getCsrfCookie(),
    },
  });
}
// backend: stateless double-submit validation
export function doubleSubmitMiddleware(req: Request, res: Response, next: NextFunction): void {
  if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) { next(); return; }
  const cookieToken = req.cookies?.csrf_token as string | undefined;
  const headerToken = req.headers['x-csrf-token'] as string | undefined;
  if (!cookieToken || !headerToken || cookieToken !== headerToken) {
    res.status(403).json({ error: 'CSRF validation failed' });
    return;
  }
  next();
}

Step 5 — Token Rotation on Authentication State Changes

Token reuse across authentication state transitions enables session fixation attacks. Rotate immediately after:

async function onLoginSuccess(req: Request, userId: string): Promise<void> {
  // 1. Regenerate the session to prevent session fixation
  await new Promise<void>((resolve, reject) =>
    req.session.regenerate(err => (err ? reject(err) : resolve()))
  );
  // 2. Issue a fresh CSRF token bound to the new session
  req.session.csrfToken = generateCsrfToken();
  req.session.userId = userId;
}

async function onLogout(req: Request, res: Response): Promise<void> {
  await new Promise<void>((resolve, reject) =>
    req.session.destroy(err => (err ? reject(err) : resolve()))
  );
  res.clearCookie('session_id');
  res.clearCookie('csrf_token');
  res.redirect('/login');
}

Edge Cases & Bypass Patterns

If csrf_token is set on .example.com (parent domain) rather than app.example.com, a compromised other.example.com sub-domain can read and replay it. Fix: bind Set-Cookie: Domain=app.example.com explicitly, or use HMAC-signed tokens that tie the token to the session ID — a stolen raw value is then useless without the server secret.

2. CORS Misconfiguration Undoes CSRF Tokens

Setting Access-Control-Allow-Origin: * combined with Access-Control-Allow-Credentials: true is rejected by browsers per spec, but some frameworks accept the misconfiguration and respond anyway. An attacker can then script a cross-origin fetch that reads your CSRF token from the response body. Fix: enumerate an explicit origin allowlist; never use wildcard when credentials are involved. See Secure HTTP Header Configuration for a full CORS header policy.

3. Flash / Clickjacking-Assisted Token Extraction

Legacy Flash plugins and clickjacking allow an attacker to cause the victim’s browser to perform a credentialed cross-origin GET and exfiltrate the response (including CSRF tokens in HTML). Fix: set X-Frame-Options: DENY and frame-ancestors 'none' in your CSP. Ensure all token embedding uses HttpOnly where the JS client does not need direct access.

4. Login CSRF

Most CSRF analysis focuses on authenticated state, but login forms are also vulnerable. An attacker can fix a victim’s session by submitting the login form with the attacker’s credentials, then wait for the victim to enter sensitive data. Fix: use the same synchronizer token pattern on the login endpoint, regenerate the session on successful login (step 5 above), and set SameSite=Lax on the pre-auth session cookie.

5. WebSocket Upgrade Hijack

The initial HTTP/1.1 Upgrade handshake for WebSockets is a GET request with cookies attached. The CORS preflight does not apply to WebSocket upgrades. An attacker can therefore open a socket to your server using the victim’s session cookie. Fix: validate a CSRF token passed as a query parameter or in the Sec-WebSocket-Protocol header during the upgrade handshake, before the socket is accepted by application logic.

For a deep-dive into stateless token implementations, see Implementing Double-Submit Cookie Pattern in Node.js.

Automated Testing & CI Validation

Unit Tests (Vitest / Jest)

import { describe, it, expect, beforeEach } from 'vitest';
import request from 'supertest';
import { app } from '../src/app';

describe('CSRF middleware', () => {
  let agent: ReturnType<typeof request.agent>;
  let token: string;

  beforeEach(async () => {
    agent = request.agent(app);
    // Seed a session and capture the token
    const res = await agent.get('/api/csrf-token');
    token = res.body.csrfToken;
  });

  it('allows POST with valid CSRF token', async () => {
    const res = await agent
      .post('/api/update-profile')
      .set('X-CSRF-Token', token)
      .send({ email: '[email protected]' });
    expect(res.status).toBe(200);
  });

  it('rejects POST with missing CSRF token', async () => {
    const res = await agent
      .post('/api/update-profile')
      .send({ email: '[email protected]' });
    expect(res.status).toBe(403);
    expect(res.body.code).toBe('CSRF_TOKEN_INVALID');
  });

  it('rejects POST with tampered CSRF token', async () => {
    const res = await agent
      .post('/api/update-profile')
      .set('X-CSRF-Token', token.replace(/a/g, 'b'))
      .send({ email: '[email protected]' });
    expect(res.status).toBe(403);
  });

  it('rejects GET-based state mutations', async () => {
    // Verify that the endpoint doesn't exist as GET (idempotency enforcement)
    const res = await agent.get('/api/update-profile');
    expect([404, 405]).toContain(res.status);
  });
});

CI/CD Gate (GitHub Actions)

name: Security — CSRF Regression

on:
  pull_request:
    paths:
      - 'src/**'
      - 'test/**'

jobs:
  csrf-tests:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'
      - run: npm ci
      - name: Run CSRF unit tests
        run: npm run test -- --reporter=verbose --testPathPattern=csrf
      - name: OWASP ZAP active scan (CSRF detection)
        uses: zaproxy/action-full-[email protected]
        with:
          target: 'http://localhost:3000'
          rules_file_name: '.zap/csrf-rules.tsv'
          fail_action: true
      - name: Upload ZAP report
        uses: actions/upload-artifact@v4
        if: always()
        with:
          name: zap-csrf-report
          path: report_html.html

ZAP’s automated attack surface discovery will flag endpoints that accept state-changing requests without a CSRF token header or hidden field. Integrate this scan in every PR pipeline.

Compliance Mapping

Framework Control Requirement Satisfied By
OWASP ASVS v4.0 V3.4.1 Session tokens protected against CSRF Synchronizer token or double-submit cookie on all state-changing endpoints
OWASP ASVS v4.0 V5.1.1 All state-changing requests must be protected Centralized validation middleware before route handlers
NIST SP 800-53 Rev 5 SC-23 Session Authenticity CSPRNG token generation; timingSafeEqual comparison; rotation on auth state change
NIST SP 800-53 Rev 5 SI-10 Information Input Validation Strict equality check of token against session store or HMAC verification
SOC 2 Type II CC6.1 Logical and physical access controls Token-gated mutations prevent unauthorized state changes by third-party origins
SOC 2 Type II CC6.6 Logical access security measures Documented CSRF policy, CI gate enforcement, audit log of 403 rejections
PCI DSS v4.0 Req 6.2.4 Secure development practices Validate cross-origin requests; prevent unauthorized state changes
ISO 27001:2022 A.8.26 Application security requirements CSRF controls in security requirements for web-facing applications

Common Pitfalls Checklist

Frequently Asked Questions

Is SameSite=Lax sufficient on its own, or do I still need CSRF tokens?

SameSite=Lax blocks cross-site POSTs initiated from a third-party page, which eliminates the majority of classical CSRF vectors. However, it does not protect against same-site sub-domain attacks (if any sub-domain is compromised), browser bugs in older Chromium / Safari versions, top-level navigations with unsafe methods in some browser configurations, or non-browser clients. Cryptographic CSRF tokens remain the required defense-in-depth layer for applications with meaningful security or compliance requirements.

How should a single-page application handle CSRF tokens without a server-rendered HTML page?

On the first authenticated request after login, the server sets a csrf_token cookie (readable by JavaScript — do not set HttpOnly). The SPA reads it via document.cookie and injects the value into every state-changing request as the X-CSRF-Token header. The API server verifies the header matches the cookie without querying session state. Never store the token in localStorage or sessionStorage.

Do REST APIs that use JWT Bearer tokens require CSRF protection?

Only if they also accept session cookies as a fallback authentication path. A pure JWT-in-header API — where the Authorization: Bearer <token> header is the only accepted auth mechanism — is immune to CSRF because browsers do not automatically attach the Authorization header to cross-origin requests. Hybrid architectures (cookie-based web UI, JWT-based mobile/API) must validate CSRF tokens on every endpoint reachable via cookie auth.

Use a minimum of 128 bits (16 bytes) from a CSPRNG — the examples above use 32 bytes (256 bits) for additional margin. Rotate on: session creation, successful login (combined with session ID regeneration), password reset completion, privilege escalation (e.g. sudo / MFA step-up), and explicit logout. Invalidate immediately on logout; do not leave tokens valid after the session is destroyed.