CSRF Prevention: Cross-Site Request Forgery Defense Guide

Type: Software Reference Confidence: 0.95 Sources: 7 Verified: 2026-02-27 Freshness: 2026-02-27

TL;DR

Constraints

Quick Reference

Attack Vectors & Defenses

#Attack VectorRiskVulnerable PatternSecure Pattern
1Hidden form POSTCriticalForm action targets victim site, browser auto-sends session cookieRequire CSRF token in hidden field, validate server-side
2Auto-submitting form via JSCritical<form> + document.forms[0].submit() on attacker pageSameSite=Strict cookie + CSRF token validation
3Image tag GETHigh<img src="https://bank.com/transfer?to=attacker">Never use GET for state changes; validate Sec-Fetch-Site
4XHR/fetch from attacker originHighNo CORS preflight for simple requests (application/x-www-form-urlencoded)Require custom header (X-CSRF-Token) or application/json Content-Type
5Subdomain cookie tossingHighNaive double-submit cookie without HMAC signingUse signed double-submit cookie with HMAC and session binding
6Login CSRFMediumNo CSRF protection on login formUse pre-session token for unauthenticated forms
7Cross-origin WebSocketMediumWebSocket handshake carries cookies without origin checkValidate Origin header on WebSocket upgrade
8Flash/Silverlight-based (legacy)LowOutdated plugins bypass SameSite restrictionsDeprecated; ensure plugins are disabled

Prevention Methods Comparison

#MethodStrengthStateful?Framework SupportLimitations
1Synchronizer Token PatternStrongYes (server session)Django, Rails, Spring, ASP.NETRequires server-side session storage
2Signed Double-Submit CookieStrongNo (stateless)csrf-csrf (Express), customMust HMAC-sign with server secret; naive version is vulnerable
3Fetch Metadata (Sec-Fetch-Site)StrongNoExpress middleware, customLegacy browsers without Fetch Metadata need fallback
4Custom Request HeadersModerateNoAngular (X-XSRF-TOKEN), AxiosOnly works for AJAX; forms cannot set custom headers
5SameSite CookiesModerateNoAll browsers (default Lax)Bypassed by same-site cross-origin attacks
6Origin/Referer ValidationModerateNoManual implementationReferer can be stripped; Origin absent in some requests

Decision Tree

START: What type of state-changing requests does your app make?
├── HTML form submissions (POST)?
│   ├── YES → Use Synchronizer Token Pattern (hidden field + server session)
│   │         Django: {% csrf_token %}, Spring: _csrf.token, Rails: authenticity_token
│   └── NO ↓
├── AJAX/fetch only (SPA architecture)?
│   ├── YES → Require custom header (X-CSRF-Token) or use application/json Content-Type
│   │         + signed double-submit cookie for stateless validation
│   └── NO ↓
├── Both forms and AJAX?
│   ├── YES → Synchronizer token for forms + custom header with same token for AJAX
│   └── NO ↓
├── Stateless API with JWT (no session cookies)?
│   ├── YES → CSRF protection not needed (no cookies = no CSRF risk)
│   └── NO ↓
├── Microservices behind API gateway?
│   ├── YES → Validate Fetch Metadata (Sec-Fetch-Site) at gateway level
│   └── NO ↓
└── DEFAULT → Synchronizer Token Pattern + SameSite=Lax cookies + Fetch Metadata

Step-by-Step Guide

1. Set SameSite attribute on all session cookies

SameSite prevents the browser from sending cookies with cross-site requests. Set it to Strict where possible, Lax as minimum. [src1]

Set-Cookie: sessionid=abc123; SameSite=Lax; Secure; HttpOnly; Path=/
Set-Cookie: __Host-sessionid=abc123; SameSite=Strict; Secure; HttpOnly; Path=/

Verify: Browser DevTools > Application > Cookies -- confirm SameSite attribute is set. Test cross-site form submission -- cookie should NOT be sent with POST.

2. Implement framework-native CSRF token protection

Use your framework's built-in CSRF middleware rather than building your own. [src6]

# Django settings.py -- enabled by default
MIDDLEWARE = [
    'django.middleware.csrf.CsrfViewMiddleware',
]
# Templates: <form method="post">{% csrf_token %} ... </form>

Verify: Submit a form without the CSRF token -- should receive 403 Forbidden.

3. Add Fetch Metadata validation middleware

Fetch Metadata headers let you reject cross-site requests at the server level before any application logic runs. [src7]

function fetchMetadataPolicy(req, res, next) {
  const site = req.headers['sec-fetch-site'];
  if (!site) return next();
  if (['same-origin', 'same-site', 'none'].includes(site)) return next();
  if (req.headers['sec-fetch-mode'] === 'navigate'
      && req.method === 'GET') return next();
  res.status(403).json({ error: 'Cross-site request blocked' });
}
app.use(fetchMetadataPolicy);

Verify: Send a cross-origin POST from a different domain -- should receive 403.

4. Validate Origin and Referer headers

Check that the Origin (or Referer) header matches your expected domain on state-changing requests. [src1]

ALLOWED_ORIGINS = {'https://example.com', 'https://www.example.com'}

def validate_origin(request):
    origin = request.headers.get('Origin')
    if origin and origin not in ALLOWED_ORIGINS:
        return False
    return True

Verify: Set Origin header to https://evil.com in a request tool -- should be rejected.

5. Protect login and unauthenticated forms

Login forms are vulnerable to CSRF (login CSRF) even without an active session. Use a pre-session token. [src1]

from django.views.decorators.csrf import csrf_protect

@csrf_protect
def login_view(request):
    if request.method == 'POST':
        # CsrfViewMiddleware validates token before session exists
        pass

Verify: Submit the login form without csrfmiddlewaretoken -- should return 403.

Code Examples

Python/Django: Complete CSRF Protection

# settings.py
MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
]
CSRF_COOKIE_SAMESITE = 'Strict'
CSRF_COOKIE_SECURE = True
CSRF_COOKIE_HTTPONLY = True
SESSION_COOKIE_SAMESITE = 'Strict'
SESSION_COOKIE_SECURE = True

# views.py
from django.shortcuts import render

def transfer(request):
    if request.method == 'POST':
        # Token validated by CsrfViewMiddleware
        amount = request.POST['amount']
        recipient = request.POST['recipient']
    return render(request, 'transfer.html')

# For AJAX: read token from cookie or hidden field
# fetch('/api/transfer', {
#   method: 'POST',
#   headers: { 'X-CSRFToken': csrfToken },
#   body: JSON.stringify({ amount: 100 })
# });

Node.js/Express: Signed Double-Submit Cookie

const express = require('express');            // ^4.18.0
const { doubleCsrf } = require('csrf-csrf');   // ^3.0.0
const cookieParser = require('cookie-parser'); // ^1.4.0
const app = express();
app.use(cookieParser());
app.use(express.urlencoded({ extended: true }));

const { doubleCsrfProtection, generateToken } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: '__Host-csrf',
  cookieOptions: {
    sameSite: 'strict', secure: true,
    httpOnly: true, path: '/'
  },
  getTokenFromRequest: (req) =>
    req.headers['x-csrf-token'] || req.body._csrf
});
app.use(doubleCsrfProtection);

app.get('/form', (req, res) => {
  const token = generateToken(req, res);
  res.send(`<form method="POST" action="/transfer">
    <input type="hidden" name="_csrf" value="${token}">
    <button type="submit">Transfer</button>
  </form>`);
});

Java/Spring Boot: Built-in CSRF with Thymeleaf

// SecurityConfig.java -- Spring Security 6.x
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    http.csrf(csrf -> csrf
        .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    );
    return http.build();
}
// Thymeleaf auto-injects hidden _csrf field in th:action forms
// For AJAX: read token from meta tag, set in X-CSRF-TOKEN header

// For stateless JWT APIs (no session cookies):
// http.csrf(csrf -> csrf.disable())
//   .sessionManagement(sm ->
//     sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS));

Anti-Patterns

Wrong: Using GET for state-changing operations

<!-- BAD -- GET requests are not protected by CSRF tokens -->
<a href="/api/delete-account?confirm=true">Delete Account</a>
<!-- Attacker embeds: <img src="/api/delete-account?confirm=true"> -->

Correct: Use POST with CSRF token for state changes

<!-- GOOD -- POST with CSRF token -->
<form method="POST" action="/api/delete-account">
  <input type="hidden" name="csrf_token" value="{{csrf_token}}">
  <button type="submit">Delete Account</button>
</form>

Wrong: Naive double-submit cookie without HMAC signing

// BAD -- unsigned double-submit cookie vulnerable to cookie tossing
const token = crypto.randomBytes(32).toString('hex');
res.cookie('csrf', token);
// Attacker on subdomain sets their own csrf cookie + matching value

Correct: Signed double-submit cookie with HMAC

// GOOD -- HMAC-signed token bound to session
const crypto = require('crypto');
function generateCsrfToken(sessionId, secret) {
  const random = crypto.randomBytes(16).toString('hex');
  const hmac = crypto.createHmac('sha256', secret)
    .update(sessionId + random).digest('hex');
  return hmac + '.' + random;
}
function validateCsrfToken(token, sessionId, secret) {
  const [hmac, random] = token.split('.');
  const expected = crypto.createHmac('sha256', secret)
    .update(sessionId + random).digest('hex');
  return crypto.timingSafeEqual(Buffer.from(hmac), Buffer.from(expected));
}

Wrong: Disabling CSRF protection for convenience

// BAD -- disabling CSRF on session-based app
http.csrf(csrf -> csrf.disable());
// "It works now!" -- and so do CSRF attacks

Correct: Disable only for stateless JWT APIs

// GOOD -- disable CSRF only when session cookies are not used
http
  .csrf(csrf -> csrf.disable())
  .sessionManagement(sm ->
    sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
  .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
// No session cookie = no CSRF risk

Wrong: Using the deprecated csurf package

// BAD -- csurf deprecated (Sep 2022) with known bypasses
const csurf = require('csurf');  // DO NOT USE
app.use(csurf());

Correct: Use csrf-csrf or framework-native protection

// GOOD -- csrf-csrf implements signed double-submit cookie
const { doubleCsrf } = require('csrf-csrf');  // ^3.0.0
const { doubleCsrfProtection } = doubleCsrf({
  getSecret: () => process.env.CSRF_SECRET,
  cookieName: '__Host-csrf',
  cookieOptions: { sameSite: 'strict', secure: true, httpOnly: true }
});
app.use(doubleCsrfProtection);

Common Pitfalls

Diagnostic Commands

# Check SameSite attribute on cookies
curl -sI -b cookies.txt https://your-site.com/login | grep -i set-cookie

# Test CSRF protection -- submit without token (should fail)
curl -X POST https://your-site.com/api/transfer \
  -H "Cookie: sessionid=valid_session" \
  -d "amount=100&recipient=attacker"
# Expected: 403 Forbidden

# Verify Origin header validation
curl -X POST https://your-site.com/api/transfer \
  -H "Origin: https://evil.com" \
  -H "Cookie: sessionid=valid_session" \
  -d "amount=100"
# Expected: 403 Forbidden

# Scan for CSRF vulnerabilities with OWASP ZAP
docker run -t ghcr.io/zaproxy/zaproxy:stable zap-baseline.py \
  -t https://your-site.com -r report.html

# Find forms missing CSRF tokens
grep -rn '<form' --include="*.html" . | grep -v 'csrf\|_token'

Version History & Compatibility

Standard/FrameworkVersionStatusCSRF Feature
SameSite CookiesChrome 80+ (Feb 2020)Default LaxBrowser default SameSite=Lax for all cookies
Fetch Metadata (Sec-Fetch-*)Chrome 76+, Firefox 90+, Safari 16.1+Supported>98% global browser coverage
Django CsrfViewMiddlewareDjango 1.2+ (current: 6.0)Built-in, enabled by defaultSynchronizer token + Origin validation + BREACH masking
Spring Security CSRFSpring Security 4.0+ (current: 6.x)Built-in, enabled by defaultSynchronizer token + CookieCsrfTokenRepository + BREACH protection
Express csrf-csrfv3.xActive, recommendedSigned double-submit cookie pattern
Express csurfDeprecated Sep 2022Archived, DO NOT USEKnown cookie tossing bypass
Rails protect_from_forgeryRails 2.0+ (current: 8.x)Built-in, enabled by defaultSynchronizer token + Origin check

When to Use / When Not to Use

Use WhenDon't Use WhenUse Instead
App uses session cookies for authenticationAPI uses stateless JWT tokens (no cookies)Standard Authorization header validation
HTML forms submit state-changing requestsAll requests use application/json with custom headersCORS preflight provides implicit CSRF protection
App has both browser and API consumersServer-to-server communication only (no browser)Mutual TLS or API key authentication
Login forms need protection from login CSRFStatic read-only site with no state changesNo CSRF protection needed

Important Caveats

Related Units