{% csrf_token %} (Django), csrf-csrf middleware (Express), http.csrf(Customizer.withDefaults()) (Spring Security) -- use your framework's built-in CSRF protection.| # | Attack Vector | Risk | Vulnerable Pattern | Secure Pattern |
|---|---|---|---|---|
| 1 | Hidden form POST | Critical | Form action targets victim site, browser auto-sends session cookie | Require CSRF token in hidden field, validate server-side |
| 2 | Auto-submitting form via JS | Critical | <form> + document.forms[0].submit() on attacker page | SameSite=Strict cookie + CSRF token validation |
| 3 | Image tag GET | High | <img src="https://bank.com/transfer?to=attacker"> | Never use GET for state changes; validate Sec-Fetch-Site |
| 4 | XHR/fetch from attacker origin | High | No CORS preflight for simple requests (application/x-www-form-urlencoded) | Require custom header (X-CSRF-Token) or application/json Content-Type |
| 5 | Subdomain cookie tossing | High | Naive double-submit cookie without HMAC signing | Use signed double-submit cookie with HMAC and session binding |
| 6 | Login CSRF | Medium | No CSRF protection on login form | Use pre-session token for unauthenticated forms |
| 7 | Cross-origin WebSocket | Medium | WebSocket handshake carries cookies without origin check | Validate Origin header on WebSocket upgrade |
| 8 | Flash/Silverlight-based (legacy) | Low | Outdated plugins bypass SameSite restrictions | Deprecated; ensure plugins are disabled |
| # | Method | Strength | Stateful? | Framework Support | Limitations |
|---|---|---|---|---|---|
| 1 | Synchronizer Token Pattern | Strong | Yes (server session) | Django, Rails, Spring, ASP.NET | Requires server-side session storage |
| 2 | Signed Double-Submit Cookie | Strong | No (stateless) | csrf-csrf (Express), custom | Must HMAC-sign with server secret; naive version is vulnerable |
| 3 | Fetch Metadata (Sec-Fetch-Site) | Strong | No | Express middleware, custom | Legacy browsers without Fetch Metadata need fallback |
| 4 | Custom Request Headers | Moderate | No | Angular (X-XSRF-TOKEN), Axios | Only works for AJAX; forms cannot set custom headers |
| 5 | SameSite Cookies | Moderate | No | All browsers (default Lax) | Bypassed by same-site cross-origin attacks |
| 6 | Origin/Referer Validation | Moderate | No | Manual implementation | Referer can be stripped; Origin absent in some requests |
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
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.
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.
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.
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.
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.
# 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 })
# });
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>`);
});
// 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));
<!-- 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"> -->
<!-- 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>
// 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
// 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));
}
// BAD -- disabling CSRF on session-based app
http.csrf(csrf -> csrf.disable());
// "It works now!" -- and so do CSRF attacks
// 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
// BAD -- csurf deprecated (Sep 2022) with known bypasses
const csurf = require('csurf'); // DO NOT USE
app.use(csurf());
// 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);
__Host- cookie prefix. [src2]# 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'
| Standard/Framework | Version | Status | CSRF Feature |
|---|---|---|---|
| SameSite Cookies | Chrome 80+ (Feb 2020) | Default Lax | Browser default SameSite=Lax for all cookies |
| Fetch Metadata (Sec-Fetch-*) | Chrome 76+, Firefox 90+, Safari 16.1+ | Supported | >98% global browser coverage |
| Django CsrfViewMiddleware | Django 1.2+ (current: 6.0) | Built-in, enabled by default | Synchronizer token + Origin validation + BREACH masking |
| Spring Security CSRF | Spring Security 4.0+ (current: 6.x) | Built-in, enabled by default | Synchronizer token + CookieCsrfTokenRepository + BREACH protection |
| Express csrf-csrf | v3.x | Active, recommended | Signed double-submit cookie pattern |
| Express csurf | Deprecated Sep 2022 | Archived, DO NOT USE | Known cookie tossing bypass |
| Rails protect_from_forgery | Rails 2.0+ (current: 8.x) | Built-in, enabled by default | Synchronizer token + Origin check |
| Use When | Don't Use When | Use Instead |
|---|---|---|
| App uses session cookies for authentication | API uses stateless JWT tokens (no cookies) | Standard Authorization header validation |
| HTML forms submit state-changing requests | All requests use application/json with custom headers | CORS preflight provides implicit CSRF protection |
| App has both browser and API consumers | Server-to-server communication only (no browser) | Mutual TLS or API key authentication |
| Login forms need protection from login CSRF | Static read-only site with no state changes | No CSRF protection needed |