Back to issues
SECURITY

Why Does CSRF Still Catch So Many Developers Off Guard?

Understand how CSRF attacks work, why the browser is the unwitting villain, and how to implement real protection with tokens, cookies, and headers.

By Thiago Saraiva8 MIN

Mental Model: Think of your session cookie as a house key your browser carries everywhere. CSRF is someone sliding a note under your door that says "please do X" -- your browser obediently uses the key to open the door and do it, no questions asked. A CSRF token is a secret password the house asks for on top of the key. The note-slider doesn't know it.

CSRF (Cross-Site Request Forgery) exploits something we accept as normal: the browser automatically sends cookies with any request to a domain. It doesn't matter where the request came from. If the cookie is there, it goes along for the ride.

A real story that should scare you: In January 2008, security researcher Rsnake disclosed a CSRF flaw in uTorrent's web UI so trivial that an attacker could change the admin password or queue arbitrary downloads just by getting a logged-in user to load an <img> tag pointing at localhost. The same year, a Princeton/Stanford paper documented live CSRF holes on ING Direct (fund transfers), YouTube, and the New York Times. These aren't textbook CVEs. They're billion-dollar companies that shipped this bug.

The Real Problem

Imagine this scenario:

  1. You log into bank.com and the browser stores the session cookie
  2. You open another tab and visit cool-forum.com
  3. Hidden on that page is some HTML like this:
  1. The browser makes the GET request to bank.com with your session cookie
  2. The server receives a perfectly legitimate authenticated request
  3. Money transferred. You didn't even notice.

The server can't tell the difference: did the request come from you clicking a button, or from a malicious site? Both carry the same cookie. It's the digital equivalent of a bouncer who only checks the wristband, never the face.

(Yes, a real bank shouldn't accept GET for a transfer. That's the point. Plenty of apps still do.)

How CSRF Tokens Solve This

The idea is simple: include something in the request that a third-party site can't obtain.

A malicious site can make the browser send cookies (automatic), but can't read the content of pages from another domain (Same-Origin Policy). Think of Same-Origin Policy as a one-way mirror: other sites can throw things at your window, but they can't peek inside.

  1. The server generates a secret token and stores it in the session
  2. It includes this token in the HTML body (hidden field) or exposes it to same-origin JS
  3. The form sends the token along with the POST
  4. The server compares: submitted token === session token

The attacker can make the browser send cookies, but can't read your site's HTML to extract the token. Without the token, the request is rejected with a 403.

Which Strategy Should You Pick? (Decision Tree)

Most "decision trees" online are just bullet lists wearing a hat. Actually walk this one. Each branch ends with a concrete pick and a one-line reason.

Q1. Do you keep server-side sessions?

  • Yes -> go to Q2
  • No (JWT, fully stateless) -> go to Q3

Q2. Is this security-critical (banking, healthcare, admin, anything regulated)?

  • Yes -> Synchronizer Token. The token never leaves your server. Stop reading, ship it.
  • No -> Synchronizer is still the safe default. Double Submit is fine if you want to avoid session writes on hot paths.

Q3. What does the client actually send?

  • HTML forms exist on the site -> go to Q4
  • JSON only, same-origin SPA -> Custom Header + strict CORS. Cheapest correct answer.
  • JSON only, cross-origin SPA -> Signed Double Submit. Plain double submit leaks under subdomain takeover.

Q4. Do you serve multiple subdomains (app.site.com, admin.site.com)?

  • Yes -> Signed Double Submit. The HMAC binds the token to the session, so a hijacked sibling subdomain can't forge one.
  • No -> Double Submit Cookie. Simpler, good enough.

Mobile app on the same API? Mobile clients don't auto-attach cookies, so they're not CSRF-able by definition. But your web client still is. Don't disable CSRF "because the mobile app doesn't need it" -- gate it per client.

The 4 Implementation Patterns

1. Synchronizer Token (the classic)

Token generated on the server, stored in the session, validated on every mutation. The most secure approach.

Advantage: Token never leaves the server/session. Disadvantage: Requires server-side state.

2. Double Submit Cookie (stateless)

Useful when you don't have a server-side session (APIs with JWT).

This works because the attacker can make the browser send the cookie, but can't read it (Same-Origin Policy). Same one-way mirror, different window.

3. Signed Double Submit

Fixes the subdomain vulnerability of the previous pattern by adding HMAC:

4. Custom Header (the simplest for JSON APIs)

If your API only accepts JSON, just require a custom header. HTML forms can't send custom headers -- only JavaScript can. And cross-origin JS hits the CORS preflight wall before the request ever lands.

This is only safe if you also reject application/x-www-form-urlencoded, multipart/form-data, and text/plain on mutation endpoints. Those three are CORS "simple" content types and skip the preflight.

Pitfalls I've Seen in Production

Non-constant-time comparison: Using === to compare tokens leaks timing info. With enough samples an attacker can recover the token byte by byte. Use crypto.timingSafeEqual() on equal-length buffers.

Token in the URL: Putting the CSRF token as a query parameter leaks it into access logs, Referer headers, and browser history. Always send it in the body or a header.

Forgetting internal routes: Protecting /transfer and /delete-account but forgetting /api/settings or /api/email/change. Apply protection globally and make explicit exceptions.

SameSite=Lax lets GET through: Lax still allows top-level GET navigation with cookies. If any of your mutations accept GET, Lax does nothing for you. Make mutations POST/PUT/PATCH/DELETE, period.

CSRF vs SameSite: Does One Replace the Other?

No. Use both. As of 2026, SameSite=Lax is the default in Chrome, Firefox, Edge, and Safari, and it killed most drive-by CSRF. But "most" isn't "all". SameSite=None is still required for legitimate cross-site auth (embedded widgets, federated SSO), subdomain takeovers bypass Lax entirely, and a misconfigured Domain=.example.com cookie shares with every sibling. CSRF tokens don't care about any of that. Defense in depth: SameSite=Lax + CSRF Token. Belt and suspenders. Your pants aren't falling down today.

What About GraphQL / tRPC?

Short answer: same rules, different shape. GraphQL usually speaks JSON over a single POST /graphql endpoint, so the Custom Header pattern fits like a glove, as long as you reject application/x-www-form-urlencoded (yes, you can smuggle GraphQL mutations through a form if you're careless). tRPC by default uses POST with JSON content-type, same deal. The catch: if you enable GET queries for caching, those become CSRF-able for any mutation-like side effect -- so keep mutations strictly POST and gate them with a header check. Apollo Server ships csrfPrevention: true and turns it on by default in v4+. Leave it on.

FAQ

Is CSRF still a thing in 2026 with modern browsers? Yes. SameSite=Lax is the default everywhere now, which killed a big chunk of trivial CSRF. But "a big chunk" isn't "all of it". Subdomain takeovers, SameSite=None cookies (required for cross-site auth), misconfigured Domain attributes, and GET-based mutations still leave holes. Don't skip the token.

My API uses JWT in Authorization: Bearer. Do I need CSRF protection? No, if -- and only if -- the JWT lives in localStorage or memory and is attached manually by JS. Browsers don't auto-send Authorization headers. But the moment you put that JWT in a cookie (which a lot of people do for XSS reasons), you're back on the CSRF menu.

Can't I just check the Referer or Origin header? Use them as an extra layer, not the primary defense. Referer can be stripped by privacy settings, extensions, or proxies. Origin is more reliable and is sent on all CORS and same-origin POSTs in modern browsers, so an Origin allowlist makes a strong second check. Never the only one.

How long should a CSRF token live? Per-session is standard. Per-request (rotating) is more secure but breaks the back button and parallel tabs -- users will hate you. Pick per-session unless you're building something nuclear.

What's the difference between CSRF and XSS? XSS = attacker runs JS inside your site. CSRF = attacker makes your browser hit your site from elsewhere. XSS defeats CSRF tokens entirely (the malicious script can just read them), so fix your XSS first. CSRF protection assumes your site isn't already compromised.

Key Takeaways

  • CSRF exploits the browser's automatic cookie sending
  • The defense is to include something in the request that the attacker can't obtain
  • For apps with sessions: use Synchronizer Token
  • For stateless SPAs: use Signed Double Submit
  • For JSON-only APIs: Custom Header is fine, but CORS and content-type allowlists must be tight
  • Always compare tokens with crypto.timingSafeEqual()
  • SameSite=Lax is a complement, not a substitute -- and it does nothing for GET mutations