Cheatsheet
Cross-Site Request Forgery defenses fit on one page. A correct setup combines SameSite cookies, a per-request token (or origin-bound bearer token), strict CORS, and JSON-only content types. Use this lesson as the short reference; lock the patterns down in your codebase.
Cookies, tokens, CORS, headers, tests
EXAMPLE
// 1) Cookie defaults — the cheap baseline
res.cookie('session', token, {
httpOnly: true, // XSS can't read the cookie
secure: true, // only over HTTPS
sameSite: 'lax', // sent on top-level nav, NOT cross-site subresource
maxAge: 1000 * 60 * 60 * 24 * 7,
});
// SameSite=Strict is tighter (no cross-site sending at all) but breaks 'click an email link to go to the app'.
// Lax is the right default for most apps.
// 2) Double-submit CSRF token — best for cookie-auth SPAs and forms
import crypto from 'node:crypto';
app.use((req, res, next) => {
let csrf = req.cookies['csrf-token'];
if (!csrf) {
csrf = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', csrf, {
sameSite: 'lax', secure: true,
// NOT HttpOnly — SPA must be able to read it
});
}
res.locals.csrf = csrf;
next();
});
function requireCsrf(req, res, next) {
if (['GET', 'HEAD', 'OPTIONS'].includes(req.method)) return next();
const header = req.get('X-CSRF-Token');
const cookie = req.cookies['csrf-token'];
if (!header || !cookie ||
!crypto.timingSafeEqual(Buffer.from(header), Buffer.from(cookie))) {
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 wiring
import axios from 'axios';
import Cookies from 'js-cookie';
const api = axios.create({ baseURL: '/api', 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;
});
// 3) Per-form synchroniser token — for server-rendered pages
// (Express + connect-csurf style; csurf itself is unmaintained — roll your own or use express-csrf-double-submit)
// In the template:
// <form method="POST" action="/profile">
// <input type="hidden" name="csrf" value="<%= csrf %>">
// <input name="displayName">
// <button>Save</button>
// </form>
app.post('/profile', (req, res, next) => {
if (req.body.csrf !== req.cookies['csrf-token']) return res.sendStatus(403);
next();
});
// 4) Custom-header requirement (forces a CORS preflight)
function requireJson(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();
}
app.use(requireJson);
// Browsers do not send a custom 'X-Requested-With' or non-simple Content-Type cross-origin
// without a CORS preflight. Combined with a strict CORS allowlist, simple form POSTs
// from attacker sites cannot reach the endpoint.
// 5) Strict CORS — never reflect origin, never wildcard with credentials
const ALLOWED = new Set(['https://app.example.com', 'https://admin.example.com']);
app.use((req, res, next) => {
const origin = req.get('origin');
if (origin && ALLOWED.has(origin)) {
res.setHeader('Access-Control-Allow-Origin', origin);
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');
res.setHeader('Access-Control-Max-Age', '600');
return res.sendStatus(204);
}
next();
});
// 6) Bearer tokens in Authorization header — CSRF disappears as a class
// The browser doesn't auto-attach Authorization, so attacker sites can't reach the API.
// Trade-off: tokens stored client-side are reachable via XSS; pair with strict CSP.
let accessToken = null;
api.interceptors.request.use((cfg) => {
if (accessToken) cfg.headers.Authorization = `Bearer ${accessToken}`;
return cfg;
});
// 7) Login CSRF — yes, the login form needs CSRF too
function issuePreLoginToken(req, res, next) {
if (!req.cookies['pre-login-csrf']) {
const t = crypto.randomBytes(32).toString('hex');
res.cookie('pre-login-csrf', t, { sameSite: 'lax', secure: true, maxAge: 30 * 60 * 1000 });
}
next();
}
app.get('/login', issuePreLoginToken, renderLogin);
app.post('/login', (req, res, next) => {
if (!req.body.csrf || req.body.csrf !== req.cookies['pre-login-csrf']) return res.sendStatus(403);
next();
});
// Otherwise attackers can sign victims into attacker-controlled accounts and harvest data added later.
// 8) GET safety — GET endpoints must be IDEMPOTENT and have no side effects
// CSRF defenses skip GET; if a GET changes state, no token will save you.
// Bad: GET /api/account/delete // change to POST/DELETE
// Bad: GET /api/transfer?amount=100 // change to POST with token
// 9) Sensitive flows — require re-auth or a recent step-up
app.post('/account/email-change', requireRecentAuth({ maxAgeSeconds: 300 }), changeEmail);
app.post('/account/disable-mfa', requireRecentAuth({ maxAgeSeconds: 60 }), disableMfa);
// Even if a CSRF defense slips, the user has to give a fresh password / TOTP — small blast radius.
// 10) Same-Origin headers (defence in depth)
// X-Frame-Options: DENY stops clickjacking → less reliance on cookies sent in iframes
// Cross-Origin-Opener-Policy: same-origin isolates window.opener attacks
// Cross-Origin-Resource-Policy: same-site limits cross-origin embeds reading responses
// 11) Logout — invalidate server-side
app.post('/logout', requireCsrf, async (req, res) => {
await sessions.destroy(req.sessionID);
res.clearCookie('session');
res.sendStatus(204);
});
// Don't rely on cookie expiry only; an attacker with the cookie keeps it until exp.
// 12) Token rotation
function rotateCsrfOnLogin(req, res, next) {
const t = crypto.randomBytes(32).toString('hex');
res.cookie('csrf-token', t, { sameSite: 'lax', secure: true });
next();
}
app.post('/login', rotateCsrfOnLogin, handleLogin);
// 13) Regression tests
import request from 'supertest';
test('state-change without CSRF header is rejected', async () => {
const res = await request(app)
.post('/api/profile')
.set('Cookie', `session=${valid}; csrf-token=${csrf}`)
.send({ displayName: 'x' });
expect(res.status).toBe(403);
});
test('CORS does not reflect unknown origin', async () => {
const res = await request(app)
.options('/api/profile')
.set('Origin', 'https://evil.example.com')
.set('Access-Control-Request-Method', 'POST');
expect(res.headers['access-control-allow-origin']).toBeUndefined();
});
// 14) Monitoring
// • Counter — csrf.failed by route + IP
// • Alert at sudden spikes — likely an attempted attack OR a deploy that broke token issuance
// • Compare requests with vs without Origin header — anomalies often indicate machine clients abusing endpoints
// 15) Common bugs
// • SameSite=None without Secure → browsers reject — debug as 'cookie not being sent'
// • Wildcard CORS + Allow-Credentials: true → standards say browsers ignore; treat as misconfig
// • GET endpoints that mutate state → no CSRF defense applies
// • CSRF cookie marked HttpOnly → JS can't read it, double-submit silently fails
// • Frontend storing token in localStorage → vulnerable to XSS; in-memory + refresh-cookie is safer
// • Per-request token without timing-safe compare → leaks token presence via response time
// • Forgetting CSRF on JSON APIs because 'we use bearer tokens' — but cookies are also issued for browsers
// 16) One-page checklist
// ✓ session cookie: HttpOnly + Secure + SameSite=Lax (or Strict)
// ✓ Double-submit CSRF token on every state-changing endpoint
// ✓ Custom-header requirement (Content-Type: application/json)
// ✓ Strict CORS allowlist; never reflect Origin; Vary: Origin
// ✓ Login form has a pre-session CSRF token
// ✓ Sensitive flows require recent re-auth
// ✓ GET endpoints never have side effects
// ✓ Logout invalidates server-side session
// ✓ Token rotation on privilege change
// ✓ Regression tests for missing / mismatched token + cross-origin probes
// ✓ Monitoring + alerting on csrf.failed counters
Why it matters
Use this lesson as your one-page reference: SameSite=Lax cookies, double-submit CSRF tokens, strict CORS allowlist, JSON-only content type, login-CSRF defense, recent-auth gate on sensitive flows. None of the controls alone is enough; together they make it boring to break in.
Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.
Example
Example
// SameSite=Lax cookies | CSRF token (synchronizer or double-submit) // Origin check | Don't mutate on GET | Custom header forces preflightTry it Yourself »
Discussion
Loading…