Security — CSRF Protection
Cross-Site Request Forgery (CSRF) is an attack that forces an authenticated user to execute unwanted actions on a web application. The attacker exploits the fact that browsers automatically include credentials (cookies, HTTP auth) with every request to the target domain.
CSRF does not steal data directly — it makes the victim perform actions like changing their email, transferring money, or modifying permissions. Unlike XSS, the attacker never sees the response. The damage is done server-side through the victim's authenticated session.
The attack relies on three conditions: the target site uses cookies for authentication, the victim is authenticated, and the attacker can construct a request to the target site. The browser sends cookies automatically — no JavaScript needed.
| 1 | <!-- CSRF attack — hidden form on attacker's site --> |
| 2 | <!-- Victim visits evil.com while logged into bank.com --> |
| 3 | |
| 4 | <!-- Method 1: Auto-submitting form --> |
| 5 | <body onload="document.getElementById('csrf-form').submit()"> |
| 6 | <form id="csrf-form" |
| 7 | action="https://bank.com/transfer" |
| 8 | method="POST" |
| 9 | style="display:none"> |
| 10 | <input type="hidden" name="to" value="attacker-account" /> |
| 11 | <input type="hidden" name="amount" value="10000" /> |
| 12 | </form> |
| 13 | </body> |
| 14 | |
| 15 | <!-- Method 2: Image tag (GET-based CSRF) --> |
| 16 | <!-- Only works if the endpoint accepts GET for state changes --> |
| 17 | <img src="https://bank.com/transfer?to=attacker&amount=10000" |
| 18 | style="display:none" /> |
| 19 | |
| 20 | <!-- Method 3: AJAX with credentials --> |
| 21 | <script> |
| 22 | fetch('https://bank.com/transfer', { |
| 23 | method: 'POST', |
| 24 | credentials: 'include', // Browser sends cookies |
| 25 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 26 | body: 'to=attacker&amount=10000', |
| 27 | }); |
| 28 | </script> |
| 29 | |
| 30 | <!-- Method 4: Fetch with no-cors (opaque response) --> |
| 31 | <!-- Attacker can't read the response, but the request is sent --> |
| 32 | <script> |
| 33 | fetch('https://bank.com/transfer', { |
| 34 | method: 'POST', |
| 35 | mode: 'no-cors', |
| 36 | credentials: 'include', |
| 37 | headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, |
| 38 | body: 'to=attacker&amount=10000', |
| 39 | }); |
| 40 | </script> |
warning
The synchronizer token pattern is the most widely used CSRF defense. The server generates a unique, unpredictable token for each user session and embeds it in forms or headers. The server validates the token on every state-changing request. Since the attacker cannot read the token (Same-Origin Policy), they cannot forge a valid request.
| 1 | // Synchronizer Token — Express implementation |
| 2 | import crypto from 'crypto'; |
| 3 | import express from 'express'; |
| 4 | |
| 5 | const app = express(); |
| 6 | app.use(express.urlencoded({ extended: true })); |
| 7 | app.use(express.json()); |
| 8 | |
| 9 | // Session-based token storage |
| 10 | // Token is bound to the user's session |
| 11 | app.use((req, res, next) => { |
| 12 | if (!req.session.csrfToken) { |
| 13 | req.session.csrfToken = crypto.randomBytes(32).toString('hex'); |
| 14 | } |
| 15 | next(); |
| 16 | }); |
| 17 | |
| 18 | // Generate and expose token to forms |
| 19 | app.get('/transfer', (req, res) => { |
| 20 | res.send(` |
| 21 | <form method="POST" action="/transfer"> |
| 22 | <input type="hidden" name="_csrf" value="${req.session.csrfToken}" /> |
| 23 | <input type="text" name="amount" placeholder="Amount" /> |
| 24 | <input type="text" name="to" placeholder="Recipient" /> |
| 25 | <button type="submit">Transfer</button> |
| 26 | </form> |
| 27 | `); |
| 28 | }); |
| 29 | |
| 30 | // Validate token on state-changing requests |
| 31 | function validateCsrf(req, res, next) { |
| 32 | const token = req.body._csrf || req.headers['x-csrf-token']; |
| 33 | |
| 34 | if (!token) { |
| 35 | return res.status(403).json({ error: 'CSRF token missing' }); |
| 36 | } |
| 37 | |
| 38 | // Constant-time comparison |
| 39 | const isValid = crypto.timingSafeEqual( |
| 40 | Buffer.from(token), |
| 41 | Buffer.from(req.session.csrfToken) |
| 42 | ); |
| 43 | |
| 44 | if (!isValid) { |
| 45 | return res.status(403).json({ error: 'Invalid CSRF token' }); |
| 46 | } |
| 47 | |
| 48 | next(); |
| 49 | } |
| 50 | |
| 51 | // Apply to all state-changing routes |
| 52 | app.post('/transfer', validateCsrf, (req, res) => { |
| 53 | const { amount, to } = req.body; |
| 54 | // Process transfer... |
| 55 | res.json({ success: true, amount, to }); |
| 56 | }); |
| 57 | |
| 58 | app.post('/profile', validateCsrf, (req, res) => { |
| 59 | // Update profile... |
| 60 | res.json({ success: true }); |
| 61 | }); |
| 62 | |
| 63 | app.post('/settings/password', validateCsrf, (req, res) => { |
| 64 | // Change password... |
| 65 | res.json({ success: true }); |
| 66 | }); |
info
The double submit cookie is a stateless CSRF defense. The server sets a random token in a non-HttpOnly cookie, and the client includes the same value in a request header or body parameter. The server verifies they match. This works because an attacker can trigger a request but cannot read cookies from another origin.
| 1 | // Double Submit Cookie — stateless CSRF protection |
| 2 | import crypto from 'crypto'; |
| 3 | import express from 'express'; |
| 4 | |
| 5 | const app = express(); |
| 6 | app.use(express.json()); |
| 7 | app.use(cookieParser()); |
| 8 | |
| 9 | // Set CSRF cookie on GET requests |
| 10 | app.get('/api/csrf-token', (req, res) => { |
| 11 | const token = crypto.randomBytes(32).toString('hex'); |
| 12 | res.cookie('csrf_token', token, { |
| 13 | secure: process.env.NODE_ENV === 'production', |
| 14 | sameSite: 'lax', |
| 15 | httpOnly: false, // Must be readable by JavaScript |
| 16 | maxAge: 3600000, // 1 hour |
| 17 | }); |
| 18 | res.json({ csrfToken: token }); |
| 19 | }); |
| 20 | |
| 21 | // Validate double submit on state-changing requests |
| 22 | function validateDoubleSubmit(req, res, next) { |
| 23 | const cookieToken = req.cookies?.csrf_token; |
| 24 | const headerToken = req.headers['x-csrf-token']; |
| 25 | |
| 26 | if (!cookieToken || !headerToken) { |
| 27 | return res.status(403).json({ error: 'CSRF token missing' }); |
| 28 | } |
| 29 | |
| 30 | if (cookieToken.length !== headerToken.length) { |
| 31 | return res.status(403).json({ error: 'Invalid CSRF token' }); |
| 32 | } |
| 33 | |
| 34 | // Constant-time comparison — prevents timing attacks |
| 35 | const isValid = crypto.timingSafeEqual( |
| 36 | Buffer.from(cookieToken), |
| 37 | Buffer.from(headerToken) |
| 38 | ); |
| 39 | |
| 40 | if (!isValid) { |
| 41 | return res.status(403).json({ error: 'Invalid CSRF token' }); |
| 42 | } |
| 43 | |
| 44 | // Optional: rotate token after each use |
| 45 | const newToken = crypto.randomBytes(32).toString('hex'); |
| 46 | res.cookie('csrf_token', newToken, { |
| 47 | secure: process.env.NODE_ENV === 'production', |
| 48 | sameSite: 'lax', |
| 49 | httpOnly: false, |
| 50 | maxAge: 3600000, |
| 51 | }); |
| 52 | |
| 53 | next(); |
| 54 | } |
| 55 | |
| 56 | app.post('/api/transfer', validateDoubleSubmit, (req, res) => { |
| 57 | res.json({ success: true }); |
| 58 | }); |
| 59 | |
| 60 | // Frontend — fetch token, then include in every request |
| 61 | async function initCsrf() { |
| 62 | const res = await fetch('/api/csrf-token', { credentials: 'include' }); |
| 63 | const { csrfToken } = await res.json(); |
| 64 | // Store in memory, not localStorage |
| 65 | window.__csrfToken = csrfToken; |
| 66 | } |
| 67 | |
| 68 | // Add to every fetch request |
| 69 | async function secureFetch(url, options = {}) { |
| 70 | const response = await fetch(url, { |
| 71 | ...options, |
| 72 | credentials: 'include', |
| 73 | headers: { |
| 74 | ...options.headers, |
| 75 | 'X-CSRF-Token': window.__csrfToken, |
| 76 | 'Content-Type': 'application/json', |
| 77 | }, |
| 78 | }); |
| 79 | return response; |
| 80 | } |
best practice
The SameSite cookie attribute is a browser-enforced defense that controls whether cookies are sent with cross-origin requests. It is the most effective single CSRF defense for modern browsers, though it should be layered with other protections for older browsers.
| Value | Behavior | CSRF Protection | Trade-off |
|---|---|---|---|
| Strict | Never sent cross-site | Full CSRF protection | Breaks links from external sites |
| Lax | Sent on top-level GET navigations only | Good (prevents most CSRF) | Safe default for most applications |
| None | Sent on all cross-site requests | No CSRF protection | Requires Secure flag, for third-party only |
| 1 | // SameSite cookie configuration |
| 2 | import session from 'express-session'; |
| 3 | |
| 4 | // Recommended: SameSite=Lax (safe default) |
| 5 | app.use(session({ |
| 6 | secret: process.env.SESSION_SECRET, |
| 7 | cookie: { |
| 8 | httpOnly: true, |
| 9 | secure: process.env.NODE_ENV === 'production', |
| 10 | sameSite: 'lax', // CSRF protection + good UX |
| 11 | maxAge: 24 * 60 * 60 * 1000, |
| 12 | }, |
| 13 | })); |
| 14 | |
| 15 | // Sensitive operations: SameSite=Strict |
| 16 | // Use a separate cookie or session for admin/banking routes |
| 17 | app.use('/api/admin', (req, res, next) => { |
| 18 | // Admin session with Strict SameSite |
| 19 | res.cookie('admin_session', adminToken, { |
| 20 | httpOnly: true, |
| 21 | secure: true, |
| 22 | sameSite: 'strict', // Maximum CSRF protection |
| 23 | maxAge: 4 * 60 * 60 * 1000, |
| 24 | }); |
| 25 | next(); |
| 26 | }); |
| 27 | |
| 28 | // Third-party embeds: SameSite=None (must have Secure) |
| 29 | // Only when you genuinely need cross-site cookies |
| 30 | app.use(session({ |
| 31 | cookie: { |
| 32 | sameSite: 'none', |
| 33 | secure: true, // Required when SameSite is None |
| 34 | }, |
| 35 | })); |
| 36 | |
| 37 | // Setting SameSite via raw header (for non-session cookies) |
| 38 | res.setHeader('Set-Cookie', [ |
| 39 | `session=${sessionId}; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=86400`, |
| 40 | `preferences=${prefs}; Secure; SameSite=Lax; Path=/; Max-Age=31536000`, |
| 41 | ].join(', ')); |
For API-based applications, requiring a custom header (like X-Requested-With or X-CSRF-Token) provides CSRF protection because cross-origin requests cannot set custom headers without CORS preflight approval. This works because the browser blocks non-simple headers unless the server explicitly allows them.
| 1 | // Custom header CSRF defense for SPAs |
| 2 | import express from 'express'; |
| 3 | |
| 4 | const app = express(); |
| 5 | app.use(express.json()); |
| 6 | |
| 7 | // CORS configuration — do NOT allow the custom header from all origins |
| 8 | app.use((req, res, next) => { |
| 9 | const origin = req.headers.origin; |
| 10 | const allowed = ['https://app.example.com', 'https://admin.example.com']; |
| 11 | |
| 12 | if (allowed.includes(origin)) { |
| 13 | res.setHeader('Access-Control-Allow-Origin', origin); |
| 14 | res.setHeader('Access-Control-Allow-Credentials', 'true'); |
| 15 | res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-Requested-With'); |
| 16 | // Do NOT set Access-Control-Allow-Methods for * |
| 17 | } |
| 18 | |
| 19 | if (req.method === 'OPTIONS') { |
| 20 | return res.sendStatus(204); |
| 21 | } |
| 22 | next(); |
| 23 | }); |
| 24 | |
| 25 | // Middleware: require custom header on state-changing requests |
| 26 | function csrfProtection(req, res, next) { |
| 27 | // X-Requested-With is a "non-simple" header |
| 28 | // Cross-origin requests cannot set it without CORS preflight |
| 29 | const header = req.headers['x-requested-with']; |
| 30 | |
| 31 | if (!header || header !== 'XMLHttpRequest') { |
| 32 | return res.status(403).json({ |
| 33 | error: 'CSRF validation failed — missing X-Requested-With header', |
| 34 | }); |
| 35 | } |
| 36 | |
| 37 | next(); |
| 38 | } |
| 39 | |
| 40 | // Apply to state-changing API routes |
| 41 | app.post('/api/transfer', csrfProtection, (req, res) => { |
| 42 | res.json({ success: true }); |
| 43 | }); |
| 44 | |
| 45 | app.put('/api/profile', csrfProtection, (req, res) => { |
| 46 | res.json({ success: true }); |
| 47 | }); |
| 48 | |
| 49 | // Frontend — add header to all requests |
| 50 | fetch('/api/transfer', { |
| 51 | method: 'POST', |
| 52 | credentials: 'include', |
| 53 | headers: { |
| 54 | 'Content-Type': 'application/json', |
| 55 | 'X-Requested-With': 'XMLHttpRequest', |
| 56 | }, |
| 57 | body: JSON.stringify({ amount: 100, to: 'recipient' }), |
| 58 | }); |
info
The Origin and Referer headers indicate where a request came from. Validating these headers on the server can block CSRF attacks, but they should be used as defense-in-depth, not as the sole protection.
| 1 | // Origin / Referer validation middleware |
| 2 | import { URL } from 'url'; |
| 3 | |
| 4 | const TRUSTED_ORIGINS = [ |
| 5 | 'https://example.com', |
| 6 | 'https://app.example.com', |
| 7 | 'https://admin.example.com', |
| 8 | ]; |
| 9 | |
| 10 | function validateOrigin(req, res, next) { |
| 11 | // Prefer Origin header (sent with POST/PUT/DELETE) |
| 12 | const origin = req.headers.origin; |
| 13 | |
| 14 | if (origin) { |
| 15 | if (!TRUSTED_ORIGINS.includes(origin)) { |
| 16 | return res.status(403).json({ error: 'Invalid origin' }); |
| 17 | } |
| 18 | return next(); |
| 19 | } |
| 20 | |
| 21 | // Fall back to Referer header (may be absent) |
| 22 | const referer = req.headers.referer; |
| 23 | if (referer) { |
| 24 | try { |
| 25 | const refererUrl = new URL(referer); |
| 26 | const refererOrigin = refererUrl.origin; |
| 27 | if (!TRUSTED_ORIGINS.includes(refererOrigin)) { |
| 28 | return res.status(403).json({ error: 'Invalid referer' }); |
| 29 | } |
| 30 | return next(); |
| 31 | } catch { |
| 32 | return res.status(403).json({ error: 'Invalid referer format' }); |
| 33 | } |
| 34 | } |
| 35 | |
| 36 | // Neither Origin nor Referer present — allow for same-site, block for cross-site |
| 37 | // Some privacy tools strip these headers, so don't reject outright |
| 38 | // Use as additional signal, not sole defense |
| 39 | next(); |
| 40 | } |
| 41 | |
| 42 | // Apply to sensitive endpoints |
| 43 | app.post('/api/transfer', validateOrigin, csrfProtection, (req, res) => { |
| 44 | res.json({ success: true }); |
| 45 | }); |
| 46 | |
| 47 | // Double-submit with origin check (defense in depth) |
| 48 | app.post('/api/admin/delete-user', validateOrigin, csrfProtection, (req, res) => { |
| 49 | res.json({ success: true }); |
| 50 | }); |
warning
Most modern frameworks include built-in CSRF protection. Understanding what they provide and what you still need to configure is essential.
| 1 | // Next.js App Router — Server Actions have CSRF protection built in |
| 2 | // Forms that call Server Actions are automatically protected |
| 3 | |
| 4 | // app/actions.ts |
| 5 | 'use server'; |
| 6 | export async function transferMoney(formData: FormData) { |
| 7 | const amount = Number(formData.get('amount')); |
| 8 | const to = formData.get('to') as string; |
| 9 | // CSRF is verified automatically by Next.js |
| 10 | await processTransfer(amount, to); |
| 11 | } |
| 12 | |
| 13 | // app/transfer/page.tsx |
| 14 | import { transferMoney } from '../actions'; |
| 15 | export default function TransferPage() { |
| 16 | return ( |
| 17 | <form action={transferMoney}> |
| 18 | <input type="number" name="amount" /> |
| 19 | <input type="text" name="to" /> |
| 20 | <button type="submit">Transfer</button> |
| 21 | </form> |
| 22 | ); |
| 23 | } |
| 24 | |
| 25 | // ──────────────────────────────────────────── |
| 26 | |
| 27 | // Express with csurf middleware |
| 28 | import csrf from 'csurf'; |
| 29 | const csrfProtection = csrf({ cookie: true }); |
| 30 | |
| 31 | app.get('/form', csrfProtection, (req, res) => { |
| 32 | res.render('form', { csrfToken: req.csrfToken() }); |
| 33 | }); |
| 34 | |
| 35 | app.post('/process', csrfProtection, (req, res) => { |
| 36 | // Token validated automatically by middleware |
| 37 | res.send('Processed'); |
| 38 | }); |
| 39 | |
| 40 | // ──────────────────────────────────────────── |
| 41 | |
| 42 | // Django — CSRF enabled by default |
| 43 | // {% csrf_token %} in templates |
| 44 | // @csrf_protect decorator for views |
| 45 | // CSRF_COOKIE_SECURE = True in settings |
| 46 | |
| 47 | // Ruby on Rails — built into forms |
| 48 | // <%= form_with(model: @user) do |f| %> |
| 49 | // <!-- CSRF token auto-included --> |
| 50 | // <% end %> |
| 51 | |
| 52 | // Laravel — @csrf directive in Blade templates |
| 53 | // csrf_token() helper generates the token |
| 54 | |
| 55 | // ASP.NET Core — Antiforgery |
| 56 | // [ValidateAntiForgeryToken] attribute on controllers |
| Method | State | Browser Support | Complexity |
|---|---|---|---|
| Synchronizer Token | Server-side session | All browsers | Medium |
| Double Submit Cookie | Stateless | All browsers | Low |
| SameSite Cookies | Browser-enforced | Modern browsers (2020+) | Very Low |
| Custom Header | Stateless | All browsers (with CORS) | Low |
| Origin Validation | Stateless | Not always present | Low |
- Use SameSite=Lax as the default for all session cookies — it prevents most CSRF without additional code.
- Layer CSRF tokens on top of SameSite for older browsers and defense in depth.
- For SPAs, use the custom header pattern (X-Requested-With) with strict CORS.
- Apply CSRF protection to all state-changing requests: POST, PUT, PATCH, DELETE.
- GET requests must never cause state changes — follow HTTP semantics strictly.
- Use crypto.timingSafeEqual for token comparison to prevent timing attacks.
- Regenerate CSRF tokens on privilege changes and session renewal.
- Never rely solely on Origin/Referer validation — use as defense in depth.
- Use framework-provided CSRF protection before implementing custom solutions.
- Test CSRF defenses with tools like OWASP ZAP or Burp Suite.
best practice