# CSRF Protection Done Right

URL: https://www.devopsness.com/blog/csrf-protection-guide
Author: Kiril Urbonas
Published: 2026-07-21
Updated: 2026-07-21
Categories: DevOps
Tags: Security, Appsec, Web Security, CSRF, Cookies

Summary: A practical guide to defending against CSRF with SameSite cookies, synchronizer tokens, and double-submit, plus knowing when you don't need any of it.

---
# 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
<form action="https://bank.example/transfer" method="POST" id="f">
  <input type="hidden" name="to" value="attacker-account">
  <input type="hidden" name="amount" value="5000">
</form>
<script>document.getElementById('f').submit();</script>
```

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:

```
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
# 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
<form method="POST" action="/transfer">
  <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
  ...
</form>
```

`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](/blog/application-security-best-practices), and if sessions and login are in scope, [authentication and session security](/blog/secure-authentication-best-practices) 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.

