CSRF protection
Require unguessable tokens or custom headers for state-changing requests; use SameSite on session cookies; add step-up auth or re-auth for sensitive actions. This page follows validation flow →tokens →cookies →methods & OAuth →embeds & testing.
The SKILL should separate “browser + cookie session—sites from “Authorization-only APIs— the former needs CSRF tokens or equivalent non-simple headers; the latter has a smaller CSRF surface but avoid accidentally mixing cookie auth back in.
CSRF Token full implementation (generate / validate / rotate)
// csrf-token.ts — complete Node.js implementation
import { randomBytes, timingSafeEqual } from 'crypto';
import { Request, Response, NextFunction } from 'express';
const CSRF_TOKEN_LENGTH = 32;
const CSRF_HEADER = 'x-csrf-token';
const CSRF_COOKIE = 'csrf_token';
// Generate a cryptographically secure random CSRF token
export function generateCsrfToken(): string {
return randomBytes(CSRF_TOKEN_LENGTH).toString('hex');
}
// Middleware: validate CSRF token (synchronizer token pattern)
export function csrfProtect(req: Request, res: Response, next: NextFunction) {
// GET/HEAD/OPTIONS do not need CSRF protection
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const sessionToken = req.session?.csrfToken as string | undefined;
const requestToken = (req.headers[CSRF_HEADER] as string)
|| req.body?._csrf;
if (!sessionToken || !requestToken) {
return res.status(403).json({ error: 'CSRF token missing' });
}
// Constant-time comparison to prevent timing attacks
const sessionBuf = Buffer.from(sessionToken, 'utf8');
const requestBuf = Buffer.from(requestToken, 'utf8');
if (sessionBuf.length !== requestBuf.length ||
!timingSafeEqual(sessionBuf, requestBuf)) {
return res.status(403).json({ error: 'CSRF token invalid' });
}
// Rotate token (update on each request to prevent replay)
req.session.csrfToken = generateCsrfToken();
next();
}
// Initialize: generate token in session after login and set cookie
export function initCsrfToken(req: Request, res: Response) {
const token = generateCsrfToken();
req.session.csrfToken = token;
res.cookie(CSRF_COOKIE, token, {
httpOnly: false, // Frontend JS needs to read it
secure: process.env.NODE_ENV === 'production',
sameSite: 'Strict',
});
return token;
}
Common framework CSRF configurations
# Django — settings.py (enabled by default, verify the following)
MIDDLEWARE = [
'django.middleware.csrf.CsrfViewMiddleware', # must be in the list
# ...
]
CSRF_COOKIE_SECURE = True # HTTPS only
CSRF_COOKIE_HTTPONLY = False # frontend JS needs to read (Double Submit pattern)
CSRF_COOKIE_SAMESITE = 'Strict'
CSRF_TRUSTED_ORIGINS = ['https://app.example.com'] # required in Django 4.0+
// Spring Security — CSRF configuration (Java)
@Configuration
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
)
.sessionManagement(sm -> sm
.sessionFixation().newSession() // rotate session after login
);
return http.build();
}
}
Double Submit Cookie pattern implementation
// Client: read token from cookie, place in request header
function getCookie(name) {
const match = document.cookie.match(new RegExp(`(^|; )${name}=([^;]+)`));
return match ? decodeURIComponent(match[2]) : null;
}
// All non-safe method requests automatically include CSRF token
fetch('/api/transfer', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': getCookie('csrf_token'), // read from cookie
},
body: JSON.stringify({ amount: 100, to: 'user-123' }),
credentials: 'same-origin',
});
// Server-side validation: compare cookie value and header value
function doubleCsrfCheck(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const cookieToken = req.cookies['csrf_token'];
const headerToken = req.headers['x-csrf-token'];
if (!cookieToken || cookieToken !== headerToken) {
return res.status(403).json({ error: 'CSRF validation failed' });
}
next();
}
State-changing request validation flow
[ Browser / client issues mutating request ]
│
▼
┌──────────────────────│
│Method semantics: │──── GET/HEAD must not change state
│not GET/HEAD │
└──────────────────────│
│
▼
┌──────────────────────│
│Trusted origin: │──── Forms: sync token; APIs: custom header or
│token / non-simple │ Bearer; fix XSS before double-submit cookie
│header / step-up │
└──────────────────────│
│
▼
┌──────────────────────│
│Cookies: SameSite & │──── Lax/Strict/None+Secure;
│session fixation │ cross-site subdomain policy separately
└──────────────────────│
│
▼
[ Run business logic ]
Order matters: fix method semantics and routing first, then tokens and cookie policy; OAuth flows use state against CSRF—related to but distinct from form tokens.
Synchronizer tokens and custom headers
Classic server-rendered forms use synchronizer CSRF tokens (session-bound; rotation policy per threat model). Double-submit cookie patterns need strict XSS control—stolen cookies bypass them.
SPAs often use Authorization: Bearer or non-simple headers like X-Requested-With to force CORS preflight, making silent cross-site requests harder; align with your CORS allowlist—no reflected Origin.
- Tokens: cryptographic random, sufficient entropy; constant-time compare server-side.
- Multipart uploads: token field or header per framework conventions.
SameSite Cookie configuration and browser compatibility
// Express.js — complete secure Cookie configuration
const sessionConfig = {
name: 'sid',
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
// SameSite selection logic:
// - Strict: strongest, cookie not sent on email link navigation (may affect UX)
// - Lax (recommended default): sent on top-level GET nav, not on cross-site POST/XHR
// - None: for cross-site embedding, must pair with Secure=true
sameSite: process.env.CROSS_SITE_EMBED ? 'None' : 'Lax',
secure: process.env.NODE_ENV === 'production',
httpOnly: true,
maxAge: 3600000, // 1 hour
}
};
// Browser support and fallback strategy
// Chrome 80+: SameSite=Lax is default (when not explicitly set)
// Safari 12: does not support SameSite=None, will ignore the cookie
// IE11/Edge Legacy: does not support SameSite, ignores it entirely
//
// Fallback: when using None, also set Secure; older browsers degrade to no SameSite
// Detection: User-Agent detection to decide whether to send None based on browser version
// Rotate session ID after successful login (prevent session fixation)
app.post('/login', async (req, res) => {
const user = await verifyCredentials(req.body);
if (!user) return res.status(401).json({ error: 'Invalid credentials' });
// Rotate session: destroy old session, create new session
req.session.regenerate((err) => {
if (err) return next(err);
req.session.userId = user.id;
req.session.csrfToken = generateCsrfToken();
res.json({ ok: true });
});
});
HTTP methods and OAuth
GET must be side-effect free; use POST, PUT, PATCH, DELETE for mutations. Avoid “delete via GET for shareable links—anti-patterns.
OAuth / OIDC authorization requests and callbacks should use state (and PKCE code_verifier); the SKILL should flag missing or weak fixed state.
Embeds, client surfaces, and testing
Cross-site iframes interact with top-level navigation, clickjacking (Content-Security-Policy: frame-ancestors), and cookie policy.
- APIs: separate browser vs native CSRF exposure; WebViews need their own tests.
- Testing: automate cross-site form posts, preflight failures, and missing-token cases; document scanner false positives.
SKILL snippet
---
name: csrf-protection-review
description: Implement or review CSRF token, SameSite cookie, Double Submit pattern, and framework configurations
---
# Steps
1. Confirm application shape: SSR form+cookie / SPA+cookie / SPA+Bearer / native client
2. SSR forms: implement synchronizer CSRF token (generateCsrfToken + timingSafeEqual validation)
3. After login: session.regenerate() rotates session ID, simultaneously refreshes CSRF token
4. Cookie config: httpOnly=true, secure=true, sameSite='Lax' (default) or 'Strict'
5. Cross-site embedding: sameSite='None'; secure=true + tighten CORS + CSRF token double protection
6. SPA + Cookie: X-CSRF-Token custom header + CORS allowlist (forbid reflected Origin)
7. Double Submit Cookie: client reads token from cookie, puts in header; server compares both
8. Express: csrf middleware or custom + timingSafeEqual constant-time comparison
9. Django: CsrfViewMiddleware + CSRF_TRUSTED_ORIGINS + CSRF_COOKIE_SAMESITE
10. Spring: CookieCsrfTokenRepository.withHttpOnlyFalse() + newSession() for fixation protection
11. OAuth/OIDC: state parameter random and session-bound; public clients use PKCE
12. GET must not change state: enforce at routing layer
# Anti-patterns
- Do NOT allow GET/HEAD to change server state
- Do NOT use fixed or predictable values for CSRF tokens
- Do NOT skip timingSafeEqual (prevents timing side-channel attacks)
Review surface checklist (page JS)
Select a shape to generate a local review memo (no network); trim to your stack before pasting into issues or SKILL self-checks.
Review memo