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.
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 single highest-leverage control is the SameSite cookie attribute. It tells the browser whether to send a cookie on cross-site requests.
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.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.
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.
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.
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.
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:
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.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.
You rarely have to build any of this by hand.
CsrfViewMiddleware on by default, using a double-submit token with the {% csrf_token %} template tag.protect_from_forgery is enabled by default with a synchronizer token via csrf_meta_tags.CookieCsrfTokenRepository option for SPAs.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.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.
Get the latest tutorials, guides, and insights on AI, DevOps, Cloud, and Infrastructure delivered directly to your inbox.
A working engineer's tour of the OWASP Top 10, each risk paired with the concrete fix that actually closes it in production.
A likelihood-ordered checklist for tracing "Permission denied" on Linux through mode bits, ownership, ACLs, SELinux, and mount options.
Explore more articles in this category
Run only the CI jobs a change actually affects using if conditionals, trigger path filters, and per-job path detection in a monorepo.
A prioritized toolkit for cutting CI time: measure the critical path first, then cache, parallelize, run only what changed, and shrink the work itself.
Stop stashing long-lived AWS access keys in GitHub secrets and let OIDC hand your workflows short-lived, scoped credentials instead.
Evergreen posts worth revisiting.