← Dev log

security

CSRF, and when a JWT setup quietly needs it anyway

CSRF, and when a JWT setup quietly needs it anyway

A malicious site tricks a logged-in user's browser into making a request to your app, and the browser automatically attaches the user's cookies — because any request to yourapp.com carries yourapp.com's cookies regardless of what page initiated it. If the app trusts "has a valid session cookie" as proof of intent, an attacker can make the browser act as the user — change an email, submit a form — without the user knowingly doing anything.

This only matters for cookie-based auth. An API strictly requiring a JWT in an Authorization header is mostly safe from it — a forged request can't attach a header value the attacker doesn't know, and same-origin policy blocks reading localStorage from another origin.

The bit that catches people

I needed CSRF protection anyway, because refresh tokens are httpOnly cookies. That's good practice — it keeps them out of reach of XSS — but it reintroduces the cookie problem: the browser sends that cookie along with a forged cross-site request too. Any endpoint reachable via a cookie-carrying request needs protection.

The standard defences, layered:

  • A double-submit or synchroniser token. The server issues a CSRF token the frontend must echo in a custom header on state-changing requests, which a forged cross-site request can't read or replay.
  • SameSite=Lax or Strict on the cookie itself, telling the browser not to send it on cross-site requests at all. Lax still allows top-level navigation like a clicked link; Strict is tighter but can break legitimate flows like a payment redirect coming back.
  • Checking Origin/Referer headers as a lighter secondary check — not airtight alone, since some proxies and extensions strip headers, but another layer.

The CSRF work in this project's history landed alongside CI and CodeQL fixes, which isn't a coincidence — CodeQL is exactly the kind of static analysis that flags a missing CSRF check on a cookie-authenticated, state-changing endpoint.