Adding CSRF Protection

SameSite=None removed the browser's built-in CSRF defence, so the app has to supply its own.

Version 1 has a gap. The cookie is SameSite=None, which is precisely the setting that tells the browser "attach this on cross-site requests". The default CSRF protection is switched off by design — so we have to add it back.

Three layers, all cheap

  1. Origin check. Reject state-changing requests whose Origin is not on the allowlist. The browser sets this header and page JavaScript cannot forge it.
  2. Double-submit token. The server sets a readable XSRF-TOKEN cookie; the client copies it into an X-XSRF-TOKEN header. An attacker's page can make the browser send the cookie but cannot read it to build the header.
  3. A custom header at all. Any non-safelisted header forces a preflight, which your CORS allowlist will refuse for an unknown origin.

Why double-submit works

The same-origin policy is the asymmetry. evil.com can cause a request to dfg.com with cookies attached — but it cannot read dfg.com's cookies, and it cannot read the response. So it can never learn the token value it would need to put in the header.

What to protect

Every state-changing method: POST, PUT, PATCH, DELETE. Not GET or HEAD — and that is only safe because your GETs genuinely do not change anything. A GET /api/notes/1/delete would be CSRF-able through an <img> tag no matter what tokens you check.

Rotate on login

Issue a fresh CSRF token whenever the session changes. Otherwise a token captured before login stays valid afterwards.

Example

Example · javascript
// Server: issue a readable token alongside the HttpOnly session
function issueCsrf(res) {
  const token = randomBytes(32).toString('base64url');
  res.cookie('XSRF-TOKEN', token, {
    httpOnly: false,        // the client MUST be able to read this one
    secure: true,
    sameSite: 'none',
    partitioned: true,
    path: '/',
  });
  return token;
}

// Client: copy cookie → header
const readCookie = (name) =>
  document.cookie.split('; ').find((c) => c.startsWith(name + '='))?.split('=')[1];

When to use it

  • A penetration test attempts a cross-site POST against the notes API and is stopped by the Origin check before the token comparison even runs.
  • A team adds CSRF protection after discovering their SameSite=None cookie removed the browser's default defence.
  • A GET route that deleted a record is rewritten as DELETE during a review, closing an image-tag CSRF vector no token could have covered.

More examples

The server-side middleware

The Origin check runs first because it is free and it rejects the whole class of attack before any token comparison is needed.

Example · javascript
import { randomBytes, timingSafeEqual } from 'crypto';

const SAFE_METHODS = new Set(['GET', 'HEAD', 'OPTIONS']);
const ALLOWED_ORIGINS = new Set(['https://abc.local:5173']);
const CSRF_COOKIE = 'XSRF-TOKEN';
const CSRF_HEADER = 'x-xsrf-token';

export function issueCsrf(res) {
  const token = randomBytes(32).toString('base64url');
  res.cookie(CSRF_COOKIE, token, {
    httpOnly: false,          // readable by the frontend — that is the point
    secure: true, sameSite: 'none', partitioned: true, path: '/',
  });
  return token;
}

export function csrf(req, res, next) {
  if (SAFE_METHODS.has(req.method)) return next();

  // Layer 1 — Origin. Set by the browser; page scripts cannot change it.
  const origin = req.get('origin');
  if (!origin || !ALLOWED_ORIGINS.has(origin)) {
    return res.status(403).json({ error: 'bad_origin' });
  }

  // Layer 2 — double submit. The attacker can send the cookie, not read it.
  const cookieToken = req.cookies[CSRF_COOKIE] ?? '';
  const headerToken = req.get(CSRF_HEADER) ?? '';
  const a = Buffer.from(cookieToken);
  const b = Buffer.from(headerToken);

  if (!a.length || a.length !== b.length || !timingSafeEqual(a, b)) {
    return res.status(403).json({ error: 'csrf_token_mismatch' });
  }

  next();
}

// --- wiring ---
// Rotate the CSRF token whenever the session changes.
app.post('/auth/login', authLimiter, async (req, res) => {
  const user = await db.verifyPassword(req.body?.email, req.body?.password);
  if (!user) return res.status(401).json({ error: 'invalid_credentials' });

  if (req.cookies[COOKIE]) sessions.delete(key(req.cookies[COOKIE]));
  res.cookie(COOKIE, createSession(user.id, req), COOKIE_OPTIONS);
  issueCsrf(res);                                    // ← fresh token per session
  res.json({ user: publicProfile(user) });
});

// Give an anonymous client a token so it can even attempt a login POST.
app.get('/auth/csrf', (req, res) => { issueCsrf(res); res.status(204).end(); });

// Protect everything that changes state.
app.use('/api', session, csrf);
app.post('/auth/logout', session, csrf, /* handler */);

The client half

The single retry on csrf_token_mismatch handles the real-world case of a token rotating in another tab — without it, users see a spurious error after logging in twice.

Example · javascript
const readCookie = (name) =>
  document.cookie.split('; ')
    .find((c) => c.startsWith(name + '='))
    ?.split('=').slice(1).join('=');

async function api(path, options = {}) {
  const method = (options.method ?? 'GET').toUpperCase();
  const needsCsrf = !['GET', 'HEAD'].includes(method);

  // If we have no token yet, ask for one before the first mutating call.
  if (needsCsrf && !readCookie('XSRF-TOKEN')) {
    await fetch(API + '/auth/csrf', { credentials: 'include' });
  }

  const headers = { 'Content-Type': 'application/json', ...options.headers };
  if (needsCsrf) {
    headers['X-XSRF-TOKEN'] = decodeURIComponent(readCookie('XSRF-TOKEN') ?? '');
  }

  const res = await fetch(API + path, { ...options, credentials: 'include', headers });

  if (res.status === 401) { showAuth(); throw new Error('unauthenticated'); }
  if (res.status === 403) {
    const body = await res.json().catch(() => ({}));
    if (body.error === 'csrf_token_mismatch') {
      // Token rotated (e.g. after login elsewhere) — refresh it and retry once.
      await fetch(API + '/auth/csrf', { credentials: 'include' });
      return api(path, { ...options, __retried: true });
    }
  }
  if (!res.ok) throw new Error((await res.json().catch(() => ({}))).error ?? res.statusText);
  return res.status === 204 ? null : res.json();
}

Discussion

  • Be the first to comment on this lesson.