CSRF in Modern Apps

Chapter 9
CSRF in Modern Apps
SPAs, JSON APIs, bearer vs cookie auth, and framework built-ins

Classic CSRF assumed server-rendered pages and cookie sessions. Modern apps — SPAs talking to JSON APIs, mobile clients, token-based auth — change the picture significantly. The single most important question for any endpoint is: how does it authenticate the request? Because that answer determines whether CSRF even applies.

The Decisive Question: Cookies or Not?

CSRF exists only because credentials are sent automatically (ambient authority, Chapter 1). So the dividing line is whether your auth credential is auto-attached by the browser:

Auth methodCredential sent automatically?CSRF-vulnerable?
Cookie sessionYes — browser attaches itYES — needs CSRF defences
Authorization: Bearer <token>No — JS must add it explicitlyNo — not classic CSRF
The key insight: bearer tokens in a header are not auto-sent
If your API authenticates via an Authorization: Bearer header that your JavaScript must explicitly attach to each request, then a cross-site attacker's forged request won't carry it — the browser doesn't auto-attach Authorization headers, and the attacker can't read the token to add it (Chapter 2). So a purely header-token API is not vulnerable to classic CSRF. The catch is in the word purely: the moment a token lives in a cookie, or you use any cookie-based session, CSRF is back on the table.

Where Teams Get This Wrong: JWT in a Cookie

A very common pattern is "we use JWTs, so we're stateless and CSRF-safe." But where the JWT is stored decides everything:

JWT stored in…Auto-sent?CSRF riskXSS risk to token
Authorization header (from JS memory)NoNo classic CSRFreadable by XSS
CookieYesCSRF-vulnerable (needs defences)HttpOnly hides from XSS
"We use JWT" says nothing about CSRF — storage location does
A JWT is just a token format; it does not imply any CSRF property. If you put the JWT in a cookie (popular because it can be HttpOnly and survives reloads), the browser auto-sends it on cross-site requests exactly like a session cookie — so you have full classic CSRF exposure and must add tokens/SameSite. If you put it in an Authorization header, you avoid classic CSRF but the token is reachable by XSS. There's a genuine trade-off (CSRF exposure vs XSS exposure), but there is no "JWT = safe." Decide based on storage, not format. This is the most common modern misconception about CSRF.

The "JSON-Only / Custom Header" API Defence

Many JSON APIs lean on the Chapter 2 fact that a forged HTML form can't set headers and can't send application/json without a preflight. So an API that requires Content-Type: application/json and rejects everything else (or requires a custom header) blocks classic form-based CSRF:

// reject state-changing requests that aren't genuine JSON API calls app.use((req, res, next) => { if (["POST", "PUT", "DELETE", "PATCH"].includes(req.method)) { if (!req.is("application/json")) return res.sendStatus(415); } next(); });
This works only if enforcement is strict — and never with cookie auth alone
The defence is real but fragile: it depends on the server actually rejecting non-JSON bodies (a permissive parser that also accepts application/x-www-form-urlencoded reopens the hole, because a form can send that), and it assumes well-behaved CORS. If the endpoint uses cookie auth, do not rely on JSON-only as the sole CSRF defence — pair it with SameSite + tokens. With header-bearer-token auth it's moot (no CSRF anyway). The point: "we only accept JSON" is a useful layer, not a guarantee.

SPA + Cookie Session: The Double-Submit Sweet Spot

A very common modern setup is an SPA that still uses a cookie session (often the simplest, most secure option since the session cookie can be HttpOnly). Here the double-submit cookie (Chapter 6) fits perfectly: the SPA's HTTP client reads the XSRF-TOKEN cookie and echoes it in an X-XSRF-TOKEN header automatically. This is exactly what Angular's HttpClient and Axios (with config) do out of the box.

// Axios: auto-read this cookie, send it back as this header axios.defaults.xsrfCookieName = "XSRF-TOKEN"; axios.defaults.xsrfHeaderName = "X-XSRF-TOKEN";

Framework Built-Ins — Don't Roll Your Own

FrameworkBuilt-in CSRF protection
DjangoCsrfViewMiddleware + {% csrf_token %} (synchronizer/double-submit hybrid); on by default
Railsprotect_from_forgery + form_authenticity_token; on by default
Spring SecurityCSRF tokens enabled by default for browser clients
Expressno built-in; use a maintained lib (csrf-csrf; the old csurf is deprecated)
Angularclient-side XSRF-TOKENX-XSRF-TOKEN double-submit, automatic
Use the framework's mechanism — hand-rolled CSRF code is where bugs live
Every example in this course is for understanding; in production, use your framework's built-in protection or a maintained library. They handle the subtle parts you'd likely get wrong: constant-time comparison, signed double-submit, token rotation, per-form tokens, and integration with the session lifecycle (the very thing behind your login-token error, Chapter 5). The deprecated Express csurf package is a cautionary tale — prefer actively maintained options like csrf-csrf.

Decision Guide

  • Cookie/session auth (SSR or SPA) → SameSite + anti-CSRF tokens (synchronizer for SSR, double-submit for SPA). CSRF fully applies.
  • Header bearer token (no cookies) → no classic CSRF; focus on XSS (token theft) and token handling instead.
  • JWT in a cookie → treat exactly like a session cookie: full CSRF defences required.
  • Mobile / native client (no browser) → no CSRF (no ambient cookie behaviour); standard token auth.

Hands-On Exercises

Exercise 1

For each API, state whether classic CSRF applies and why: (a) auth via Authorization: Bearer header from JS memory; (b) auth via a session cookie; (c) auth via a JWT stored in a cookie; (d) a native mobile app sending a token header. Tie each answer to "is the credential auto-sent by the browser?"

📄 View solution
Exercise 2

Explain why "we use JWTs so we're CSRF-safe" is wrong. Contrast JWT-in-header vs JWT-in-cookie on both CSRF exposure and XSS exposure, and state the trade-off a team is actually making when they choose cookie storage for HttpOnly.

📄 View solution
Exercise 3

An API blocks CSRF by requiring Content-Type: application/json. Describe two ways this can still fail (a permissive parser that also accepts form encoding; cookie auth with lax enforcement), and give the correct layered configuration for a cookie-authenticated SPA.

📄 View solution

Chapter 9 Quick Reference

  • The decisive question: is the auth credential auto-sent by the browser? Cookie = yes (CSRF applies); header bearer token = no
  • Authorization: Bearer (added by JS) → not classic CSRF; the attacker can't auto-send or read it
  • "We use JWT" ≠ safe — JWT in a cookie is auto-sent → full CSRF exposure; JWT in a header avoids CSRF but is XSS-readable
  • Trade-off: cookie storage (HttpOnly, CSRF-exposed) vs header storage (XSS-exposed, no CSRF) — choose by storage, not format
  • JSON-only / custom-header API defence works only with strict rejection of other content types; not sole defence under cookie auth
  • SPA + cookie session → double-submit (Angular/Axios XSRF-TOKENX-XSRF-TOKEN) is the sweet spot
  • Use framework built-ins (Django/Rails/Spring on by default; Express use csrf-csrf, not deprecated csurf) — don't hand-roll
  • Mobile/native clients: no browser ambient cookies → no CSRF
  • Next chapter: testing, pitfalls & a hardening checklist — finding CSRF and the deployable summary (incl. your login-error fix)