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.

Primary shape
Extra surfaces

Review memo


                

Back to skills More skills