← writing

CSRF Isn't Dead: Modern Attack Vectors in SPAs

The common take is that SameSite=Lax on session cookies makes CSRF a solved problem. The reality is messier.

SameSite=Lax blocks cross-site requests that use methods like POST, PUT, and DELETE — which covers the classic CSRF case. But it doesn’t protect GET endpoints that have side effects, it doesn’t help on older browsers, and it falls apart in certain subdomain and redirect scenarios that are easy to miss.

The subdomain problem

SameSite operates at the registrable domain level, not the full origin. Cookies scoped to .example.com are sent on requests from evil.example.com even with SameSite=Lax, because both share the same registrable domain.

If you have user-generated content on a subdomain, a marketing site on another, or a third-party integration that lands on your domain via redirect, you might be sharing cookie scope with something you shouldn’t be.

The fix: scope your session cookies to the specific subdomain with Domain=api.example.com, and use SameSite=Strict where you can tolerate the UX tradeoff of losing cross-site navigations.

Token-based CSRF is still the right call

For anything sensitive, a CSRF token provides defense-in-depth that SameSite alone doesn’t. The double-submit cookie pattern works well for SPAs: the server sets a random value in both a cookie and expects it echoed back as a header on state-changing requests. An attacker can forge the request, but can’t read the cookie value from another origin to include it in the header.

Set-Cookie: csrf_token=<random>; Secure; SameSite=Strict

On the client, include it on every mutating request:

fetch('/api/transfer', {
  method: 'POST',
  headers: {
    'X-CSRF-Token': getCookie('csrf_token'),
    'Content-Type': 'application/json',
  },
  body: JSON.stringify(payload),
});

The server validates that the header value matches the cookie. Because JavaScript on a different origin can’t read your cookies, a forged request from an attacker’s site can’t supply the matching token.

Custom headers as a lightweight alternative

Any non-standard request header that the server requires forces a preflight under CORS rules. A preflight from an attacker’s origin will fail — your server only allows requests from trusted origins. So requiring a header like X-Requested-With: XMLHttpRequest is a surprisingly effective CSRF control for APIs that only serve XHR/fetch clients.

This isn’t suitable for every endpoint, but for JSON APIs with proper CORS configuration it’s often enough on its own.

The takeaway: SameSite cookies raise the bar meaningfully, but they’re not a replacement for understanding the full attack surface. Layer your controls.