CSRF Protection Done Right

Cross-Site Request Forgery is the vulnerability everyone half-remembers from a security training slide and then implements wrong. Half the teams I audit ship CSRF tokens on stateless bearer-token APIs that never needed them, while the other half serve cookie-authenticated dashboards with no defense at all. The fix is understanding one mechanic: the browser sends your cookies whether or not you asked it to.

Why CSRF works at all #

CSRF exploits ambient authority. When a user logs into bank.example, the server sets a session cookie. From that point on, the browser attaches that cookie to every request to bank.example, no matter which page triggered it. That includes requests fired from a completely different site the user happens to be visiting.

Picture a user with an active banking session who opens evil.example. That page contains:

html.html





document.getElementById('f').submit();

The browser POSTs to the bank, dutifully attaching the session cookie. The bank sees a valid session and a well-formed transfer request, and it has no built-in way to know the user never intended to send it. That is the whole attack: a forged, state-changing request riding on cookies the browser auto-sends. The attacker cannot read the response (the same-origin policy blocks that), which is why CSRF targets writes, not reads.

The modern primary defense: SameSite cookies #

The single highest-leverage control is the SameSite cookie attribute. It tells the browser whether to send a cookie on cross-site requests.

Strict: the cookie is never sent on any cross-site request. Maximum safety, but it also means a user clicking a link to your app from an external site arrives logged out, which breaks a lot of flows.

Lax: the cookie is withheld on cross-site subrequests (form POSTs, fetch, iframes, images) but still sent on top-level GET navigations. A user following a link to your site stays logged in, while the forged POST above gets no cookie.

None: the cookie is sent everywhere and must be paired with Secure. Only use this when you genuinely need cross-site cookie access.

Lax is the sane default, and modern browsers now treat cookies without an explicit SameSite as Lax anyway. Set it explicitly so behavior is not left to browser defaults:

code

Set-Cookie: session=abc123; HttpOnly; Secure; SameSite=Lax; Path=/

That one attribute neutralizes the classic cross-site forged POST. But do not stop here, because SameSite has gaps worth understanding.

SameSite gotchas #

Top-level GET navigations: Lax still sends the cookie when the browser navigates the whole page via GET. If any of your state-changing endpoints respond to GET (they should not, but legacy routes exist), an attacker can trigger them with a plain <a href> or a redirect. Keep every mutation on POST/PUT/PATCH/DELETE.

Subdomains are same-site, not same-origin: SameSite treats app.example.com and evil.example.com as the same site because they share the registrable domain example.com. If an attacker controls any subdomain (a forgotten staging host, a user-content subdomain), SameSite gives you nothing against them. Origin isolation and a token still matter.

Because of these gaps, SameSite is the primary defense, not the only one. For anything sensitive, layer a token on top.

The synchronizer token pattern #

This is the classic, stateful, server-side defense. On session creation the server generates a random token, stores it server-side (bound to the session), and embeds it in every form or page. On each state-changing request the server compares the submitted token against the stored one and rejects mismatches.

python.python

# Flask-style illustration
import secrets

def issue_token(session):
token = secrets.token_urlsafe(32)
session["csrf"] = token # server-side, tied to the session
return token

def verify(request, session):
sent = request.form.get("csrf_token")
stored = session.get("csrf")
if not sent or not stored or not secrets.compare_digest(sent, stored):
abort(403)

html.html



...


evil.example cannot read the token (same-origin policy blocks reading your pages), so it cannot forge a valid request. Use compare_digest to avoid timing leaks. This pattern is robust but requires server-side state per session.

The double-submit cookie pattern #

Double-submit is the stateless variant. The server sets the CSRF token in a cookie and the client echoes it back in a header or form field. The server checks that the two match. No server-side storage needed, which is why single-page apps like it: JavaScript reads the token from a readable cookie and sets it as a request header.

The pitfalls are real. First, the token cookie must be readable by JS, so it cannot be HttpOnly, which widens the blast radius of any XSS. Second, and more subtle, the subdomain problem bites hard: an attacker on a sibling subdomain can often set a cookie on the parent domain, planting a token value they know and then submitting the matching header. This is the "cookie tossing" weakness. Mitigate it by signing the token (an HMAC bound to the session) rather than trusting a bare random value, and by using __Host- prefixed cookies so the cookie is locked to the exact host and path.

When you don't need CSRF tokens at all #

Here is the distinction that saves the most wasted effort. CSRF is a cookie problem. It exists because the browser auto-attaches cookies. If your API does not authenticate with cookies, the attack premise disappears.

A JSON API that authenticates with an Authorization: Bearer <token> header is not vulnerable to classic CSRF. The browser does not automatically attach Authorization headers to cross-site requests; your JavaScript has to set them explicitly, and cross-origin JS cannot read the token out of another origin's storage. So the forged-request scenario cannot supply valid credentials.

Two things back this up:

Token-in-header: requiring a custom header (Authorization, or even X-Requested-With) forces a request that a simple HTML form cannot produce. Anything setting a non-safelisted header triggers a CORS preflight.

CORS: a strict, allowlist-based CORS policy means the browser refuses to let cross-origin JS read responses or send credentialed non-simple requests to origins you did not permit.

Bearer-token auth: no cookie, no ambient authority, no CSRF token required. Cookie auth: ambient authority, CSRF defense mandatory. The mistake I see most is bolting CSRF tokens onto a stateless bearer API "to be safe," which adds friction and zero security. Match the defense to the auth model. For the broader picture, see our application security best practices, and if sessions and login are in scope, authentication and session security pairs with this directly.

Framework built-ins #

You rarely have to build any of this by hand.

Django: ships CsrfViewMiddleware on by default, using a double-submit token with the {% csrf_token %} template tag.

Rails: protect_from_forgery is enabled by default with a synchronizer token via csrf_meta_tags.

Spring Security: CSRF protection is on by default for browser clients, with a CookieCsrfTokenRepository option for SPAs.

Express: the old csurf package is deprecated and unmaintained. Use csrf-csrf (signed double-submit) or csrf-sync (synchronizer) instead, and lean on SameSite cookies as the base layer.

The call we'd make #

Set SameSite=Lax; Secure; HttpOnly on every session cookie as the baseline, and keep all mutations off GET. For cookie-authenticated apps, add your framework's built-in token and leave it on. Prefer the synchronizer pattern when you already keep server-side session state; use signed double-submit with __Host- cookies for stateless SPAs. And for bearer-token JSON APIs, skip CSRF tokens entirely and spend that effort on a tight CORS allowlist and solid token handling. The right defense is the one that matches how your requests actually carry credentials.