Bootcamp
A practical end-to-end CSRF bootcamp: what the attack looks like, the four standard defenses, the SPA-specific patterns, and a complete production stack. Treat this as the lesson you ship as the new-hire CSRF README.
Attack, defenses, SPA, full stack
EXAMPLE
// 1) The attack — one sentence
// A malicious page submits a request to YOUR site using the victim's already-authenticated session cookie.
// 2) The minimum viable exploit
// Attacker hosts a page on evil.example.com:
// <form action="https://your-bank.example/transfer" method="POST">
// <input type="hidden" name="to" value="attacker">
// <input type="hidden" name="amount" value="1000">
// </form>
// <script>document.forms[0].submit();</script>
//
// Victim visits the page while logged into your-bank.example. Browser sends the session cookie automatically.
// Result: transfer happens, attacker profits.
// 3) The four standard defenses
//
// (A) SameSite cookies — browsers refuse to send cookies on cross-site subresource requests
// (B) CSRF tokens (synchroniser / double-submit) — server requires a token only your origin's JS can produce
// (C) Origin / Referer check — server rejects requests whose origin isn't allowlisted
// (D) Custom request headers + JSON content-type — forces a CORS preflight; cross-origin requests fail
//
// In modern apps you typically use ALL of them at once.
// 4) SameSite cookies (the cheapest win)
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax', // strict/lax/none
maxAge: 1000 * 60 * 60 * 24 * 7,
});
// SameSite=Lax — cookie sent on top-level GET navigation (clicking a link), NOT on cross-site POSTs.
// SameSite=Strict — never sent cross-site; better but breaks 'click an email link to log in' flows.
// SameSite=None — sent everywhere; requires Secure; pre-CSRF era.
// 5) Double-submit CSRF token (modern SPA-friendly)
import crypto from 'node:crypto';
app.use((req, res, next) => {
if (!req.cookies['csrf-token']) {
const t = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', t, { sameSite: 'lax', secure: true }); // NOT HttpOnly — JS reads it
}
next();
});
function requireCsrf(req, res, next) {
if (['GET','HEAD','OPTIONS'].includes(req.method)) return next();
const cookie = req.cookies['csrf-token'];
const header = req.get('X-CSRF-Token');
if (!cookie || !header || !crypto.timingSafeEqual(Buffer.from(cookie), Buffer.from(header))) {
return res.status(403).json({ error: 'CSRF check failed' });
}
next();
}
app.post('/api/*', requireCsrf);
app.put ('/api/*', requireCsrf);
app.patch('/api/*', requireCsrf);
app.delete('/api/*', requireCsrf);
// SPA side: read cookie, attach header
import axios from 'axios';
import Cookies from 'js-cookie';
const api = axios.create({ withCredentials: true });
api.interceptors.request.use((cfg) => {
if (!['get','head','options'].includes(cfg.method)) {
cfg.headers['X-CSRF-Token'] = Cookies.get('csrf-token');
}
return cfg;
});
// 6) Synchroniser token (server-rendered forms)
// Generate one token per session, embed as hidden form field, verify on submit.
// (Same pattern; just no JS reading the cookie.)
// 7) Origin / Referer check
app.use((req, res, next) => {
if (['POST','PUT','PATCH','DELETE'].includes(req.method)) {
const origin = req.get('origin') || req.get('referer');
if (!origin || !origin.startsWith('https://app.example.com')) {
return res.status(403).json({ error: 'Origin' });
}
}
next();
});
// 8) Custom header / JSON content-type requirement
app.use((req, res, next) => {
if (['POST','PUT','PATCH','DELETE'].includes(req.method)) {
const ct = req.get('content-type') || '';
if (!ct.startsWith('application/json')) return res.status(415).json({ error: 'JSON required' });
}
next();
});
// Cross-origin POST with application/json triggers a CORS preflight (OPTIONS).
// Strict CORS allowlist makes that preflight fail for attacker origins.
// 9) Strict CORS
const ALLOWED = new Set(['https://app.example.com']);
app.use((req, res, next) => {
const o = req.get('origin');
if (o && ALLOWED.has(o)) {
res.setHeader('Access-Control-Allow-Origin', o);
res.setHeader('Access-Control-Allow-Credentials', 'true');
res.setHeader('Vary', 'Origin');
}
if (req.method === 'OPTIONS') {
res.setHeader('Access-Control-Allow-Methods', 'GET,POST,PUT,PATCH,DELETE');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-CSRF-Token');
return res.sendStatus(204);
}
next();
});
// 10) Login CSRF — protect /login too
// Issue a pre-login token via cookie before the form is shown; require it on POST.
// Otherwise attackers can sign victims into ATTACKER-controlled accounts and harvest data later.
// 11) Sensitive flows — require recent re-auth
app.post('/account/email-change', requireRecentAuth({ maxAgeSeconds: 300 }), changeEmail);
app.post('/account/disable-mfa', requireRecentAuth({ maxAgeSeconds: 60 }), disableMfa);
// 12) Bearer tokens (Authorization header) — CSRF disappears as a class
// Browsers don't auto-attach Authorization; CSRF doesn't apply.
// Trade-off: tokens stored in JS are accessible to XSS; pair with strict CSP.
// 13) Cookie + token + CORS + header check + recent-auth
function setupSecurity(app) {
app.use(cookieParser());
app.use(express.json());
app.use(corsWithAllowlist);
app.use(requireJsonContentType);
app.use(issueCsrfCookie);
app.use(checkOrigin);
app.post('/login', preLoginCsrf, handleLogin);
app.use('/api', requireCsrf);
app.post('/sensitive/*', requireRecentAuth({ maxAgeSeconds: 300 }));
}
// 14) Defensive coding rules
// • All state-changing routes are POST/PUT/PATCH/DELETE — never GET
// • Forms have hidden CSRF token; SPAs read csrf-token cookie + set header
// • CORS allowlist; never reflect Origin without validation
// • Cookie: HttpOnly + Secure + SameSite=Lax
// • Strict CSP; HSTS; secure headers (helmet.js)
// • Login form is CSRF protected
// • Sensitive flows require recent re-auth
// 15) Testing your CSRF defense
// • Browser test: open evil.html with a form pointing at /api/profile → 403
// • Supertest unit test:
import request from 'supertest';
test('rejects request without CSRF header', async () => {
const r = await request(app).post('/api/profile').send({ name: 'X' });
expect(r.status).toBe(403);
});
test('CORS preflight from unknown origin gets no allow header', async () => {
const r = await request(app).options('/api/profile')
.set('Origin', 'https://evil.example.com');
expect(r.headers['access-control-allow-origin']).toBeUndefined();
});
// 16) Monitoring
// • Counter csrf.failed per route
// • Alert on rate spike
// • Synthetic check every 5 min: hit endpoint without token → expect 403
// 17) Common bugs
// • Forgot SameSite on cookies in prod (legacy IE clients) — defaults vary
// • Cookie marked HttpOnly but SPA needs to read for double-submit — split cookies
// • CORS allows '*' with credentials — browsers ignore; treat as misconfiguration
// • Login form has no CSRF (Login-CSRF attack)
// • Token never rotates on login → fixation
// • GET endpoint with side effects (e.g. /unsubscribe) — defenseless to CSRF
// • Trusting Referer alone — empty Referer is common; require Origin or token
// • Token validation not constant-time → token leak via timing
// 18) The one-page checklist
// ✓ session cookie: HttpOnly + Secure + SameSite=Lax
// ✓ Double-submit CSRF token on state-changing endpoints
// ✓ Login form has its own CSRF token
// ✓ Strict CORS allowlist; never reflect Origin
// ✓ Require JSON content-type → forces preflight
// ✓ Recent re-auth for sensitive flows
// ✓ Regression tests + monitoring + alerts
Why it matters
A complete CSRF defense combines SameSite=Lax cookies, double-submit tokens on state-changing routes, strict CORS, JSON content-type requirement, login-CSRF protection, and recent re-auth for sensitive flows. Cover every layer because attackers find creative ways to bypass single defenses; the goal is multiple controls that all have to fail for an attack to land.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Discussion
Loading…