ays) creates a resilient posture that survives browser updates, framework migrations, and cross-origin service meshes without sacrificing performance or developer velocity.
Core Solution
Implementing production-grade CSRF protection requires a layered architecture that aligns with your authentication model, client framework, and deployment topology. The recommended pattern combines cryptographic token generation, cookie-based double submission, and strict origin validation.
Step 1: Define Scope & Authentication Model
Identify all state-changing endpoints (POST, PUT, PATCH, DELETE). Map your authentication mechanism:
- Session cookies β Double Submit + SameSite
- Bearer/JWT tokens β Custom header + Origin validation
- Hybrid β Layered approach with route-specific middleware
Step 2: Token Generation Strategy
Tokens must be cryptographically secure, unpredictable, and bound to the user session. Use crypto.getRandomValues() in modern environments or node:crypto in Node.js. Avoid predictable sequences or UUIDv4 without entropy hardening.
import { randomBytes } from 'node:crypto';
export function generateCsrfToken(): string {
return randomBytes(32).toString('hex');
}
Step 3: Middleware Implementation (Express/Fastify Compatible)
The middleware validates the double-submit token, enforces SameSite attributes, and verifies origin headers. It operates statelessly by deriving the expected token from the session or cookie store.
import type { Request, Response, NextFunction } from 'express';
import { timingSafeEqual } from 'node:crypto';
export function csrfProtection(secret: string) {
return (req: Request, res: Response, next: NextFunction) => {
// Skip safe methods
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) {
return next();
}
const cookieToken = req.cookies?.csrf_token;
const headerToken = req.headers['x-csrf-token'] as string | undefined;
if (!cookieToken || !headerToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison to prevent timing attacks
const buf1 = Buffer.from(cookieToken, 'utf8');
const buf2 = Buffer.from(headerToken, 'utf8');
if (buf1.length !== buf2.length || !timingSafeEqual(buf1, buf2)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
// Optional: Origin validation for cross-origin protection
const origin = req.headers.origin || req.headers.referer;
if (origin && !isAllowedOrigin(origin)) {
return res.status(403).json({ error: 'Invalid origin' });
}
next();
};
}
function isAllowedOrigin(origin: string): boolean {
const allowed = new Set(['https://app.example.com', 'https://admin.example.com']);
try {
const url = new URL(origin);
return allowed.has(url.origin);
} catch {
return false;
}
}
Step 4: Frontend Integration
Clients must read the token from the cookie and attach it to every state-changing request. Modern fetch or Axios interceptors handle this transparently.
// Vanilla fetch wrapper
async function secureRequest(url: string, options: RequestInit = {}) {
const csrfToken = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
const headers = new Headers(options.headers);
headers.set('x-csrf-token', csrfToken || '');
headers.set('Content-Type', 'application/json');
return fetch(url, {
...options,
headers,
credentials: 'include',
});
}
// Axios interceptor
import axios from 'axios';
axios.interceptors.request.use(config => {
const token = document.cookie
.split('; ')
.find(row => row.startsWith('csrf_token='))
?.split('=')[1];
if (token && !['GET', 'HEAD', 'OPTIONS'].includes(config.method?.toUpperCase() || '')) {
config.headers['x-csrf-token'] = token;
}
return config;
});
Architecture Decisions & Rationale
- Double Submit over Synchronizer Tokens: Eliminates server-side token storage, reducing database load and simplifying horizontal scaling. The token is validated purely through cookie/header matching.
- Constant-Time Comparison: Prevents timing side-channel attacks that could leak token validity.
- Origin Validation Fallback: Catches scenarios where cookie partitioning or third-party script injection bypasses double-submit. Strict allowlisting prevents open redirect abuse.
- Stateless Design: Aligns with microservice and serverless deployments where session affinity is unreliable.
Pitfall Guide
-
Relying exclusively on SameSite=Lax
SameSite does not protect against cross-site POST requests initiated by forms, and legacy browsers ignore it entirely. It also fails for bearer-token authentication where cookies are not used for session state. Always pair it with token validation or header checks.
-
Storing CSRF tokens in localStorage or sessionStorage
Client storage is accessible to any JavaScript running on the page. If an XSS vulnerability exists, an attacker can exfiltrate the token and forge requests with full validity. Tokens must reside in HttpOnly cookies or be generated dynamically per request without persistent storage.
-
Ignoring PUT, PATCH, and DELETE methods
CSRF protection is often applied only to POST. Modern APIs use other methods for state changes, and browsers will happily send authenticated PUT/DELETE requests from malicious origins. Validate all non-idempotent methods.
-
Weak or predictable token generation
Using Math.random(), timestamp-based strings, or unseeded UUIDs creates tokens that can be guessed or brute-forced. Always use cryptographically secure random number generators with at least 128 bits of entropy.
-
Skipping validation for internal/microservice traffic
Assuming internal services are trusted ignores compromised service accounts, lateral movement attacks, and misconfigured service meshes. Apply CSRF or equivalent identity verification to all state-changing endpoints, regardless of network boundary.
-
Inconsistent token lifecycle management
Tokens that never rotate or expire increase the window of exploitation. Implement token rotation on session renewal, logout, or privilege escalation. Invalidate tokens immediately upon security events.
-
Overly permissive Origin/Referer validation
Using regex patterns like /example\.com$/ or substring matching allows attackers to register evil-example.com or use path-based tricks. Always parse URLs, extract the origin, and match against a strict allowlist.
Production Best Practices:
- Run automated SAST/DAST scans specifically targeting CSRF bypass paths in CI/CD.
- Log token validation failures with rate limiting to detect probing attacks.
- Document CSRF requirements in API contracts and enforce via OpenAPI validation.
- Use security headers (
X-Content-Type-Options, X-Frame-Options) to reduce attack surface.
Production Bundle
Action Checklist
Decision Matrix
| Scenario | Recommended Approach | Why | Cost Impact |
|---|
| Legacy monolith with session cookies | Synchronizer Token + SameSite | Framework-native, low refactoring effort | Low |
| SPA + headless API (Bearer tokens) | Custom Header + Origin Validation | Bypasses cookie limitations, aligns with stateless auth | Medium |
| Microservice mesh with cross-origin calls | Double Submit + Origin Allowlist | Stateless, scales horizontally, mitigates subdomain risks | Low-Medium |
| Public-facing API with third-party integrations | Double Submit + Strict CORS + Rate Limiting | Prevents token theft, blocks automated abuse, maintains compatibility | Medium |
| Internal admin panel with high privilege | Double Submit + Session Binding + MFA | Highest assurance, prevents lateral movement and privilege escalation | High |
Configuration Template
// csrf.config.ts
import { randomBytes, timingSafeEqual } from 'node:crypto';
import type { Request, Response, NextFunction } from 'express';
export interface CsrfConfig {
secret: string;
cookieName: string;
headerName: string;
allowedOrigins: string[];
skipMethods?: string[];
}
export function createCsrfMiddleware(config: CsrfConfig) {
const skip = new Set(config.skipMethods || ['GET', 'HEAD', 'OPTIONS']);
return (req: Request, res: Response, next: NextFunction) => {
if (skip.has(req.method.toUpperCase())) return next();
const cookieToken = req.cookies?.[config.cookieName];
const headerToken = req.headers[config.headerName.toLowerCase()] as string | undefined;
if (!cookieToken || !headerToken) {
return res.status(403).json({ code: 'CSRF_MISSING', message: 'Token not provided' });
}
const buf1 = Buffer.from(cookieToken, 'utf8');
const buf2 = Buffer.from(headerToken, 'utf8');
if (buf1.length !== buf2.length || !timingSafeEqual(buf1, buf2)) {
return res.status(403).json({ code: 'CSRF_INVALID', message: 'Token mismatch' });
}
const origin = req.headers.origin || req.headers.referer;
if (origin && !config.allowedOrigins.includes(new URL(origin).origin)) {
return res.status(403).json({ code: 'ORIGIN_INVALID', message: 'Unauthorized origin' });
}
next();
};
}
// Usage
// const csrf = createCsrfMiddleware({
// secret: process.env.CSRF_SECRET!,
// cookieName: 'csrf_token',
// headerName: 'x-csrf-token',
// allowedOrigins: ['https://app.example.com'],
// });
// app.use(csrf);
Quick Start Guide
- Generate a token on session initialization: Call
randomBytes(32).toString('hex') and set it as an HttpOnly, Secure, SameSite=Lax cookie named csrf_token.
- Attach middleware to state-changing routes: Import the template, configure allowed origins, and apply to all
POST/PUT/PATCH/DELETE routes.
- Add frontend interceptor: Configure your HTTP client to read the cookie and attach
x-csrf-token to every non-idempotent request.
- Validate in CI: Run a lightweight test suite that sends requests without tokens, with mismatched tokens, and from unauthorized origins to confirm 403 responses.
- Deploy and monitor: Enable structured logging for CSRF rejections, set up alerts for spike patterns, and rotate tokens on session renewal.