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

SPAs & JWT vs Cookies

Single-page apps that authenticate by cookie still need CSRF protection. The browser sends cookies on cross-origin requests automatically, so a malicious page can trigger your API from a victim’s session unless the API requires something the attacker can’t forge.

SameSite, double-submit, custom header

EXAMPLE
// SCENARIO — React/Vue/Svelte SPA + JSON API + cookie auth

// ─── DEFENSE 1 — SameSite cookies (modern default) ─────────────

// Express
res.cookie('session', token, {
    httpOnly: true,
    secure:   true,
    sameSite: 'lax',     // good default — sent on top-level navigation, not subresources
    maxAge:   1000 * 60 * 60 * 24 * 7,
});

// SameSite=Strict blocks the cookie on ANY cross-site request, including the user
// clicking a link to your app. Use lax unless you have a reason for strict.

// SameSite=None requires Secure and re-opens CSRF surface — only use when you
// genuinely need cookies on cross-site embeds (rare for SPAs).

// ─── DEFENSE 2 — Double-submit cookie pattern ──────────────────
// Server sets a random non-HttpOnly token cookie. Client JS reads it and sends
// it as a header. The attacker page can't read the cookie (different origin)
// so it can't put it in the header — even though the cookie auto-sends.

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 — the SPA must read it
        });
    }
    res.locals.csrf = csrf;
    next();
});

// State-changing endpoints check the header matches the cookie
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);

// SPA side — read cookie, attach header
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;
});

// fetch equivalent
fetch('/api/comments', {
    method:  'POST',
    credentials: 'include',
    headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': csrf },
    body:    JSON.stringify({ text }),
});

// ─── DEFENSE 3 — Custom-header requirement ─────────────────────
// Browsers won't send custom headers on a simple form POST without a CORS
// preflight. Requiring 'X-Requested-With' or 'Content-Type: application/json'
// forces a preflight, which a same-origin policy + a strict CORS config blocks.

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();
});

// Strict CORS — never reflect Origin without an allowlist
const ALLOWED = new Set(['https://app.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, X-CSRF-Token');
        return res.sendStatus(204);
    }
    next();
});

// ─── DEFENSE 4 — Bearer tokens in Authorization header ─────────
// If the SPA stores the token in memory (NOT localStorage) and sends it as
// Authorization: Bearer …, the browser does not auto-attach it cross-origin —
// CSRF disappears as a class.
// Trade-off: refresh-token flows become more involved, and an XSS now has more
// reach (it can read the token). Pair with strict CSP.

let accessToken = null;          // in-memory, lost on refresh
api.interceptors.request.use((cfg) => {
    if (accessToken) cfg.headers.Authorization = `Bearer ${accessToken}`;
    return cfg;
});

// Refresh-token cookie stays HttpOnly + SameSite=Strict
app.post('/api/auth/refresh', (req, res) => { /* … */ });

// ─── DEFENSE 5 — Test for CSRF regressions ─────────────────────
test('state-changing endpoint rejects request without CSRF header', async () => {
    const res = await request(app)
        .post('/api/comments')
        .set('Cookie', `session=${validSession}; csrf-token=${csrf}`)
        .send({ text: 'hi' });
    expect(res.status).toBe(403);
});

test('OPTIONS preflight not auto-approved for unknown origin', async () => {
    const res = await request(app)
        .options('/api/comments')
        .set('Origin', 'https://evil.example.com')
        .set('Access-Control-Request-Method', 'POST');
    expect(res.headers['access-control-allow-origin']).toBeUndefined();
});

// ─── CHECKLIST ─────────────────────────────────────────────────
// 1. session cookie: HttpOnly + Secure + SameSite=Lax (or Strict)
// 2. Double-submit CSRF token on state-changing endpoints
// 3. Strict CORS allowlist; never reflect Origin
// 4. Require Content-Type: application/json on writes
// 5. CSP that blocks 'unsafe-inline' scripts (XSS would defeat any CSRF defense)
// 6. Regression tests for missing/mismatched CSRF token

Why it matters

SameSite=Lax is the baseline for cookie-auth SPAs, but it isn’t enough on its own — layer the double-submit pattern, a strict CORS allowlist, and a JSON-only content-type check so several controls have to fail before a CSRF succeeds.

Tip: Tweak the snippet with Try it Yourself », then sit the quiz at the bottom of the page.

Example

Example
// SPAs over cookies: keep CSRF defences on.
// SPAs over a bearer token in localStorage: no CSRF risk *from cookies*,
// but you now have XSS risk reading the token. Pick your trade-off carefully;
// SameSite-Strict cookies + tokens-in-cookies-with-CSRF is the modern default.
Try it Yourself »

Discussion

Loading…