Security
CSRF in the SameSite era: why the default helped but didn''''t kill it
A bank ships a “transfer funds” endpoint and feels safe — sessions are cookie-based, HTTPS everywhere, login is solid. Months later a user clicks a link in a forum post. The linked page silently auto-submits a hidden form to POST /transfer with amount=5000&to=attacker. The browser does what browsers do: it attaches the user’s session cookie to the cross-site request. The server sees a valid, authenticated session and moves the money. No password was stolen, no session was hijacked. The attacker never saw the response — they didn’t need to. The side effect already happened.
The mechanism: ambient authority
CSRF exists because of one browser behavior: cookies are ambient authority. The browser attaches a site’s cookies to every request to that site’s origin — no matter who initiated the request. A form on evil.com that posts to bank.com still gets the bank’s session cookie attached, because the browser keys cookies by destination, not by who started the navigation. The attacker doesn’t need to read the cookie or steal it (HttpOnly stops that, and is irrelevant here). They only need to make the victim’s browser send a request. The authentication comes along for free.
That’s the whole trick, and it’s why CSRF is sometimes called “the confused deputy”: the browser is a deputy acting with the user’s authority, confused into using that authority on the attacker’s behalf. Crucially, the attacker cannot read the response — the same-origin policy blocks that. CSRF is a write attack, not a read attack. It works on any endpoint where the side effect alone is the goal: transfer money, change email, delete account, promote a user to admin.
SameSite=Lax: the 2020 default that changed everything
In February 2020, Chrome 80 made SameSite=Lax the default for any cookie that doesn’t specify the attribute. Firefox and Edge followed. This was the single biggest blow to classic CSRF in the web’s history. A Lax cookie is not sent on cross-site subrequests — the evil.com form posting to bank.com no longer gets the cookie attached, so the attack above is dead by default for a POST.
But “Lax” is not “Strict,” and the gap is deliberate. Lax does send the cookie on top-level navigations using safe methods — when the user clicks a link and the URL bar changes to your site, the cookie comes along (otherwise every inbound link would log you out). Two consequences follow directly, and both are live CSRF vectors.
| Request | SameSite=Lax sends cookie? | CSRF risk |
|---|---|---|
Cross-site POST form / fetch | No | Blocked by default — the big win |
Top-level link click → cross-site GET | Yes | Open if any state-change is reachable via GET |
Cookie set SameSite=None; Secure | Yes, everywhere | All SameSite protection disabled by design |
POST within 120s of cookie set (Chrome Lax+POST) | Yes (default-Lax only) | ~2-minute window after login |
Why CSRF is not dead
GET-based state changes. If GET /account/delete or GET /transfer?to=x&amount=5000 actually mutates data, Lax happily sends the cookie on a top-level navigation. An attacker just needs the victim to click a link (or land on a page that redirects). This is why “never mutate on GET” is a real security rule, not a REST style preference.
SameSite=None. Any site that legitimately needs cross-site cookies — embedded widgets, third-party SSO, an API consumed from another origin — must set SameSite=None; Secure, which turns all SameSite protection off for that cookie. The moment you opt into cross-site, you’re back to pre-2020 and need a real token defense.
The Lax+POST window. Chrome’s default-Lax (not explicit SameSite=Lax) keeps a compatibility exception: a brand-new cookie is sent on a top-level cross-site POST for the first 120 seconds, to avoid breaking some SSO redirects. That’s a ~2-minute CSRF window right after the cookie is set. Setting SameSite=Lax explicitly removes this exception.
Inconsistent enforcement. Default-Lax is enforced by the browser. Older browsers, embedded WebViews, and some non-Chromium clients don’t all behave identically, and you cannot assume the server is protected by a client-side default. SameSite is a defense-in-depth layer, never the only lock.
Why this works
There’s a subtle distinction that bites people: the protection differs between a cookie with no SameSite attribute (browser applies default-Lax, which includes the 120s Lax+POST exception) and a cookie with an explicit SameSite=Lax (no exception, stricter). If you rely on SameSite at all, set it explicitly. Letting the browser’s default carry your security means inheriting its compatibility hacks.
The defenses that still matter
SameSite shrinks the surface; it does not replace a token defense for any endpoint that matters. Two server-side patterns remain the backbone:
- Synchronizer token pattern. The server generates a per-session (or per-request) cryptographically random token, stores it server-side, and embeds it in every form / sends it in a custom header. The attacker’s cross-site page can’t read it (same-origin policy) and can’t guess it. The server rejects any state-changing request whose token doesn’t match. Stateful, strongest, the OWASP default for server-rendered apps.
- Double-submit cookie. Stateless: the server sets a random value in a cookie and expects the same value echoed in a header / form field. The attacker can’t read your cookie to copy it into the header, so the two won’t match. Cheaper (no server storage) but weaker — vulnerable if an attacker can write cookies for your domain (e.g. a subdomain XSS), which is why OWASP now recommends the HMAC signed variant that binds the token to the session.
Add a defense-in-depth layer on sensitive endpoints: verify the Origin header (and fall back to Referer) matches your own origin, rejecting requests from foreign origins. And for JSON APIs, requiring a custom header (e.g. X-Requested-With) plus a non-simple content type forces a CORS preflight — which the attacker’s cross-site fetch cannot satisfy without your CORS policy allowing it. That’s a partial mitigation, not a full token defense, because top-level form posts and some content types skip preflight.
A server-rendered banking app with cookie sessions needs CSRF protection for its state-changing forms. Pick the primary defense.
Why does HttpOnly NOT protect against CSRF?
Your cookie sessions are SameSite=Lax. Which endpoint is still exposed to CSRF?
Order the layers of a modern CSRF defense, from the broadest cheap filter to the strongest positive proof:
- 1 Never mutate state on GET — keep all side effects on POST/PUT/PATCH/DELETE
- 2 Set cookies explicitly SameSite=Lax (or Strict) — cuts the cross-site surface
- 3 Verify the Origin header (fall back to Referer) matches your own origin
- 4 Require a synchronizer or HMAC double-submit CSRF token on every state-change
- 01Explain why CSRF works even though the attacker never sees the response and never reads the session cookie.
- 02Chrome defaults cookies to SameSite=Lax since 2020. Why is that not enough on its own, and what do you add?
CSRF works because cookies are ambient authority: the browser attaches your session cookie to any request bound for your origin, even one a malicious page started, so a forged write executes with full authentication while the attacker never reads the response. Chrome’s 2020 switch to SameSite=Lax as the default was the biggest single mitigation in web history — cross-site POSTs no longer carry the cookie — but it is not a complete defense. Lax still sends cookies on top-level GET navigations (so any state-change on GET is exposed), SameSite=None disables protection entirely for cross-site flows, and default-Lax keeps a ~120-second Lax+POST window. So the senior posture is defense-in-depth: never mutate on GET, set SameSite explicitly, verify the Origin/Referer header, and back it with a real CSRF token — the synchronizer pattern for server-rendered apps, or the HMAC-signed double-submit cookie for stateless APIs — so no single layer failing opens the door.