|$ curl https://forge-ai.dev/api/markdown?path=docs/security/csrf
$cat docs/security-—-csrf-protection.md
updated Recently·16 min read·published

Security — CSRF Protection

SecurityIntermediate🎯Free Tools
Introduction

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.

How CSRF Attacks Work

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.

csrf-attack.html
HTML
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

CSRF only works if the browser sends cookies automatically. API key-based authentication (sent in headers) is not vulnerable to CSRF because the attacker cannot force the browser to include custom headers. The risk is highest with cookie-based session authentication.
Synchronizer Token Pattern

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.

synchronizer-token.ts
TypeScript
1// Synchronizer Token — Express implementation
2import crypto from 'crypto';
3import express from 'express';
4
5const app = express();
6app.use(express.urlencoded({ extended: true }));
7app.use(express.json());
8
9// Session-based token storage
10// Token is bound to the user's session
11app.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
19app.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
31function 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
52app.post('/transfer', validateCsrf, (req, res) => {
53 const { amount, to } = req.body;
54 // Process transfer...
55 res.json({ success: true, amount, to });
56});
57
58app.post('/profile', validateCsrf, (req, res) => {
59 // Update profile...
60 res.json({ success: true });
61});
62
63app.post('/settings/password', validateCsrf, (req, res) => {
64 // Change password...
65 res.json({ success: true });
66});

info

Store the CSRF token in the server-side session, not in a cookie. An attacker can read cookies from their own domain but cannot read session data due to the Same-Origin Policy. The token should be regenerated on privilege changes and have a reasonable expiry (1-24 hours).
Double Submit Cookie Pattern

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.

double-submit-cookie.ts
TypeScript
1// Double Submit Cookie — stateless CSRF protection
2import crypto from 'crypto';
3import express from 'express';
4
5const app = express();
6app.use(express.json());
7app.use(cookieParser());
8
9// Set CSRF cookie on GET requests
10app.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
22function 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
56app.post('/api/transfer', validateDoubleSubmit, (req, res) => {
57 res.json({ success: true });
58});
59
60// Frontend — fetch token, then include in every request
61async 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
69async 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 double submit pattern is simpler than synchronizer tokens for SPAs because it does not require server-side session state. However, it is vulnerable to subdomain attacks (if an attacker controls a subdomain, they can set cookies). Use synchronizer tokens for highest security.
SameSite Cookies

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.

ValueBehaviorCSRF ProtectionTrade-off
StrictNever sent cross-siteFull CSRF protectionBreaks links from external sites
LaxSent on top-level GET navigations onlyGood (prevents most CSRF)Safe default for most applications
NoneSent on all cross-site requestsNo CSRF protectionRequires Secure flag, for third-party only
samesite-config.ts
TypeScript
1// SameSite cookie configuration
2import session from 'express-session';
3
4// Recommended: SameSite=Lax (safe default)
5app.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
17app.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
30app.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)
38res.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(', '));
Custom Header Defense

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.

custom-header-csrf.ts
TypeScript
1// Custom header CSRF defense for SPAs
2import express from 'express';
3
4const app = express();
5app.use(express.json());
6
7// CORS configuration — do NOT allow the custom header from all origins
8app.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
26function 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
41app.post('/api/transfer', csrfProtection, (req, res) => {
42 res.json({ success: true });
43});
44
45app.put('/api/profile', csrfProtection, (req, res) => {
46 res.json({ success: true });
47});
48
49// Frontend — add header to all requests
50fetch('/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 custom header approach works because CORS prevents cross-origin requests from setting non-simple headers unless the server explicitly allows them. This is why your CORS configuration is critical — if you allow all origins with credentials, this defense is bypassed.
Origin & Referer Validation

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.

origin-validation.ts
TypeScript
1// Origin / Referer validation middleware
2import { URL } from 'url';
3
4const TRUSTED_ORIGINS = [
5 'https://example.com',
6 'https://app.example.com',
7 'https://admin.example.com',
8];
9
10function 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
43app.post('/api/transfer', validateOrigin, csrfProtection, (req, res) => {
44 res.json({ success: true });
45});
46
47// Double-submit with origin check (defense in depth)
48app.post('/api/admin/delete-user', validateOrigin, csrfProtection, (req, res) => {
49 res.json({ success: true });
50});

warning

Never rely solely on Origin or Referer validation. These headers can be absent in some legitimate scenarios (privacy tools, corporate proxies, redirects). Use them as an additional layer alongside CSRF tokens or SameSite cookies.
Framework CSRF Protection

Most modern frameworks include built-in CSRF protection. Understanding what they provide and what you still need to configure is essential.

framework-csrf.ts
TypeScript
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';
6export 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
14import { transferMoney } from '../actions';
15export 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
28import csrf from 'csurf';
29const csrfProtection = csrf({ cookie: true });
30
31app.get('/form', csrfProtection, (req, res) => {
32 res.render('form', { csrfToken: req.csrfToken() });
33});
34
35app.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
CSRF Defense Comparison
MethodStateBrowser SupportComplexity
Synchronizer TokenServer-side sessionAll browsersMedium
Double Submit CookieStatelessAll browsersLow
SameSite CookiesBrowser-enforcedModern browsers (2020+)Very Low
Custom HeaderStatelessAll browsers (with CORS)Low
Origin ValidationStatelessNot always presentLow
Best Practices
  • 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

CSRF protection is a solved problem in most frameworks. Use the built-in mechanism (Next.js Server Actions, Django middleware, Rails protect_from_forgery, Laravel @csrf) rather than implementing your own. Custom implementations frequently have subtle bypasses.
$Blueprint — Engineering Documentation·Section ID: SEC-CSRF·Revision: 1.0