iwantcoding.com
🔥 Daily 👥 Rooms 🏆 Top Log in Sign up

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.

Example

Example
// 30-day CSRF bootcamp in the lesson body.
Try it Yourself »

Discussion

Loading…