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:
- Victim authenticates to
app.example.com; the browser storessession_id=abc123. - Attacker hosts
evil.example.netwith an auto-submitting form orfetch()call targetingapp.example.com/api/transfer. - Victim visits
evil.example.net; the browser issues the POST and automatically appendssession_id=abc123from its cookie jar. - 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: Bearerheader 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.
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.
Step 2 — Embed the Token and Configure Cookie Flags (NIST SP 800-53 SC-23)
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);
Step 4 — SPA Double-Submit Cookie Pattern
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
1. Sub-Domain Cookie Theft
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.
What is the recommended entropy and rotation policy for CSRF tokens?
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.
Related
- Implementing Double-Submit Cookie Pattern in Node.js — production middleware and stateless verification
- Cross-Site Scripting (XSS) Mitigation — prevents script injection that can steal CSRF tokens from the DOM
- DOM-Based Vulnerability Sanitization — closes the DOM sink vectors attackers use alongside CSRF
- Secure HTTP Header Configuration — CORS,
X-Frame-Options, and CSP headers that complement CSRF defenses - Server-Side Request Forgery (SSRF) Prevention — cross-pillar: CSRF-triggered state mutations can chain into SSRF when the server issues downstream requests
- Vulnerability Patterns & Web Mitigation Strategies — parent section