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…