CSRF Protection Done Right
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.
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:
<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.
# 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)
<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 evenX-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
CsrfViewMiddlewareon by default, using a double-submit token with the{% csrf_token %}template tag. - Rails:
protect_from_forgeryis enabled by default with a synchronizer token viacsrf_meta_tags. - Spring Security: CSRF protection is on by default for browser clients, with a
CookieCsrfTokenRepositoryoption for SPAs. - Express: the old
csurfpackage is deprecated and unmaintained. Usecsrf-csrf(signed double-submit) orcsrf-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.
Find What's Eating Your RAM on Linux
A practical method for reading free, top, and ps correctly so you attribute Linux memory to a real cause instead of guessing.
Linux "Permission Denied" — Diagnose It Fast
A likelihood-ordered checklist for tracing "Permission denied" on Linux through mode bits, ownership, ACLs, SELinux, and mount options.
More from DevOps
Explore more articles in this category
GitHub Copilot CLI Sandboxing Is GA: What Actually Changed
GitHub made local sandboxing generally available for Copilot CLI on October 7, so the default now restricts the agent instead of trusting it.
The Tensorlake npm Compromise Targeted Your AI Tool Configs
A compromised npm release went after cloud credentials as expected, and local AI coding-assistant config files, which most teams never treated as secrets.
GitLab's New Rate Limits: What to Fix Before Oct 19
GitLab is capping unauthenticated API calls at 60 an hour starting October 19, and the preview windows land before most teams will have noticed.
You might have missed
Evergreen posts worth revisiting.