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

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 preflight
Try it Yourself »

Discussion

Loading…