DeepConcepts

Security / security / browser / cookies

Lax by Default Is One Browser's Policy, Not the Web's

The misconception

That leaving SameSite off is safe because browsers now default to Lax. Chrome does. Firefox ships network.cookie.sameSite.laxByDefault=false and Safari never implemented the default at all, so on those browsers an unlabelled session cookie is attached to a cross-site top-level POST exactly as it was before SameSite existed. Even in Chrome the default is the weaker Lax-allowing-unsafe mode, which sends an unlabelled cookie on a cross-site POST for the first 120 seconds of the cookie's life — and Lax of either kind always sends the cookie on a cross-site top-level GET.

15 min

Cross-site request forgery — CSRF, where a page you did not write causes your browser to send an authenticated request to a site you are logged into — is supposed to be over. Browsers default cookies to SameSite=Lax now, so the forged request arrives without the session cookie and fails. That sentence is true of Chrome. It is not true of Firefox, and it has never been true of Safari.

The cookie specification does define the rule. It does not tell a browser to apply that rule to a cookie whose Set-Cookie header never mentioned SameSite. Chrome decided to; Firefox ships the preference network.cookie.sameSite.laxByDefault set to false; Safari never implemented it. So "we rely on the default" is a statement about one browser's product decision, not about the web.

The panel below is a browser. It holds one session cookie for https://bank.example and runs eight concrete requests at it — six that a hostile page can cause, two that your own users and your own identity provider cause every day. For each one it applies the retrieval rule from the cookie specification, then your server-side check, and prints which rule decided the outcome.

Start with the defaults — no SameSite attribute, Chrome, a 45-second-old session, no server-side check. Three of the six forged requests reach the handler. Now change only the browser to Firefox. It becomes four, and your server did not change.

"Safe" is the HTTP specification's word for a method whose semantics are read-only; RFC 9110 §9.2.1 names GET, HEAD, OPTIONS and TRACE, and everything else — POST included — is unsafe. Most frameworks exempt GET from the token check automatically, which is why the last option is a separate choice and not a stricter version of the one above it.

forged requests that reach the handler
real flows broken
requests carrying the cookie
this browser's default rule
Eight requests, one cookie

a forged request that reached the handler with the session · a legitimate flow that broke · the intended outcome. Cookie-store behaviour is modelled at the level the vendors document; storage-access grants and per-site exceptions are not simulated.

Two things come out of that panel that no documentation page states together. First, changing nothing but the browser changes the answer, because the missing-attribute case is vendor policy rather than specification. Second, the row that survives every value of SameSite — including Strict — is the one where the request comes from a subdomain, because the attribute is defined over sites and a subdomain is the same site.

The rule the browser actually runs

There is no "SameSite check". There is one condition in the cookie retrieval algorithm, and it excludes the cookie unless every clause of it holds. The current specification revision is draft-ietf-httpbis-rfc6265bis-22; §5.8.3 says to exclude a cookie whose same-site-flag is not None from a cross-site request unless all of the following are true:

  • the retrieval is an HTTP retrieval, not a script reading the cookie jar;
  • the same-site-flag is Lax or Default;
  • the request method is "safe" — RFC 9110 §9.2.1 defines that as GET, HEAD, OPTIONS or TRACE, so not POST;
  • the target browsing context is the active one or a top-level traversable.

Four clauses, one unless. Every row in the panel is that sentence being evaluated. Row A3 — a top-level navigation to a URL that changes state — satisfies all four, which is why Lax sends the cookie and why the OWASP guidance is blunt about it: "Do not use GET requests for state changing operations."

Note the fourth clause. It is about the target being top-level, not about who initiated it. A hostile page can create a top-level navigation whenever it likes — a link, a form, a scripted window. The specification says so itself in §5.6.7.1: popping a new window or triggering a top-level navigation "is only a speedbump along the road to exploitation."

"Default" is a fourth value, and it is the weak one

A cookie whose header never said SameSite does not get the flag Lax. It gets the flag Default, and what Default means is left to the browser. That is not a reading between the lines; §5.6.7.2 spells out the alternative mode by name.

Lax-allowing-unsafe waives the "safe method" clause. A browser using it sends a Default cookie on a cross-site top-level request of any method — a forged POST included. The specification permits this only for cookies with no explicit attribute, and says user agents "SHOULD restrict the enforcement to cookies which were created recently", noting that "a cookie age of 2 minutes or less" has proven reasonable.

Chrome implements exactly that. In Chromium's net/cookies, kLaxAllowUnsafeMaxAge is two minutes, and the feature that would remove the exception, SameSiteDefaultChecksMethodRigorously, is disabled by default. Move the age slider across 120 s with the browser on Chrome and the attribute unset: row A2 flips from reaches handler to no cookie. Nothing about your server changed. The cookie got older.

That window exists for a real reason, and the specification gives it: §8.8.6 describes a login flow whose "concluding step … may involve a cross-site top-level POST request to an endpoint" that needs a recently-created cookie. Row L2 is that flow. Set the attribute explicitly to Lax and L2 breaks at every age, because the exception only ever applied to cookies with no attribute. Writing SameSite=Lax by hand is strictly more restrictive than letting Chrome default to it — which is the opposite of what most teams assume they are doing when they "make the default explicit".

The other browsers are simpler and worse. Firefox's StaticPrefList.yaml has network.cookie.sameSite.laxByDefault at false; the machinery is there, off. Safari never shipped a default at all. On those browsers a cookie with no attribute is sent on every request that reaches your origin, and the only thing standing between a forged POST and your handler is whatever your server checks.

Site is not origin, and that is the hole Strict does not close

Set the attribute to Strict and every browser holds the cookie back on every cross-site request. Row A5 still reaches the handler.

A5 is a page on promo.bank.example. "Same-site" is defined over the registrable domain — the name you actually bought, bank.example — not over the origin. Every subdomain is the same site as every other. A marketing microsite, a status page, a documentation host, a customer-content subdomain, a legacy app nobody has deployed to in three years: each one can issue requests that carry your Strict session cookie, and each one is a place where a template injection or a stale dependency becomes a CSRF vector on the main application.

Row A6 is the same hole wearing a different hat. Whether http://bank.example and https://bank.example are one site is the "schemeful same-site" question, and it is browser-dependent. Chrome counts it: the stable rollout began in Chrome 88 and Chrome's own feature tracker records the feature as enabled by default from Chrome 89. Firefox ships network.cookie.sameSite.schemeful defaulted to the Nightly-build flag, so it is on in Nightly and off in the release your users have. Safari does not implement it. Switch the browser to Firefox with Strict set and A6 turns red. The fix for that row is not a cookie attribute — it is HSTS, the Strict-Transport-Security header that tells a browser never to speak plain HTTP to this name again, plus the __Host- cookie name prefix, which a browser accepts only from a secure origin, with Path=/ and no Domain attribute — so a response injected over plain HTTP cannot overwrite it.

Now switch the server check to Origin header must match with Strict still set. A5 and A6 both close. The Origin header carries scheme, host and port — it is origin-based where SameSite is site-based, so it sees exactly the two distinctions the cookie attribute is blind to. It is also absent on plenty of requests, which is why it has nothing to say about rows A1 and A3: a GET issued by an image tag or a top-level navigation sends no Origin at all, and a check that rejects only on mismatch has nothing to compare. Switch the cookie to SameSite=None; Secure with the Origin check still on: on Chrome both of those rows go red, and on Firefox and Safari only A3 does, because A1 is a cross-site subresource that third-party cookie policy has already killed. In every browser the four unsafe-method rows stay dark — the header covers exactly the half of the problem the attribute does not. OWASP recommends verifying it and describes it as "a defense in depth measure" for that reason.

What third-party cookie blocking does not do

Rows A1 and A4 go dark in Firefox and Safari no matter what you set. That is not SameSite working. It is cookie partitioning and third-party cookie blocking, and it only covers subresource requests — an image, a script, a background POST from a page that stays on the attacker's origin.

A top-level navigation to your site is not a third-party context. The address bar says bank.example, so your cookies are first-party, so no amount of third-party cookie policy touches them. WebKit said this in the same post that announced full third-party cookie blocking: developers "still need to protect against forged requests that come in through top frame navigations".

This is also where the persistent confusion with CORS lives. Cross-origin resource sharing governs whether the attacker's JavaScript may read your response. A forged request does not need the response — it needs the side effect. A no-cors POST is sent, your handler runs, the money moves, and the attacker's page gets an opaque object it cannot inspect. The request succeeded. Only the reading failed.

The configuration that actually holds

Drive the hero number to zero. Three of the five server-side checks can get there, and what separates them is how much they need from the cookie:

  • A session-bound token on every unsafe route, and no route that changes state on GET. Zero at every value of SameSite, in every browser, at every cookie age. Nothing else in the panel does that.
  • A session-bound token on unsafe routes only, and the Origin check on unsafe routes only. Both reach zero, and both reach it only when the cookie is SameSite=Strict. Set the cookie to anything else and row A3 stays open in every browser: a check that exempts safe methods has nothing to say about a GET, and no SameSite value short of Strict withholds the cookie from a top-level one.

Those last two buy the zero with Strict, and Strict is also what takes both legitimate rows down: your users arrive from an emailed link logged out, and your sign-in callback stops working. The first option costs neither of those, and does not care what the cookie says.

The cookie attribute changes how many rows are already dark before the token is consulted. It never changes whether the last row is.

That is OWASP's position stated as a mechanism rather than an opinion: the cheat sheet says SameSite "is useful as a defense-in-depth control but it does not replace a proper CSRF defense in most deployments", and directs stateful applications to the synchronizer token pattern and stateless ones to a signed double-submit cookie. It marks the naive double-submit — compare a cookie against a form field, with nothing binding either to the session — as discouraged.

Select naive double-submit cookie in the panel and look at A5. The subdomain page passes, because a page on promo.bank.example can set a cookie for bank.example, and if the check is only "cookie equals field" then the attacker supplies both halves. The signed variant binds the token to the session identifier the server issued, and that value has never been on the attacker's page.

The remaining trade is L2 — the cross-site top-level POST that is supposed to work. §8.8.2 gives the answer, and it is two cookies rather than one: a read cookie marked Lax or None that survives arriving from elsewhere, and a write cookie marked Strict whose absence forces a re-authentication before any non-idempotent action. The alternative is to scope a single SameSite=None; Secure cookie to the callback path only. Both are deliberate; picking neither and relying on the two-minute window is the accidental version of the same decision.

For OAuth and OpenID Connect the callback is bound by its own parameters rather than by a cookie — see why <code>state</code> and PKCE bind different things and what the nonce covers that neither does. Which is why exempting that one route from the token check, as the panel does, is correct and not a cheat.

Checking your own deployment

Every claim above is observable from a terminal and a browser you already have. None of it requires a scanner.

  • Read the header, not the framework config. curl -sI https://your.app/login and look at every Set-Cookie line. A line with no SameSite= in it is a cookie whose behaviour is decided by whichever browser your user opened. Middleware and proxies rewrite these; the wire is the truth.
  • Count the browsers you are relying on. Take the share of your traffic that is not Chromium and multiply. That fraction of your sessions has no SameSite protection at all if the attribute is unset — not weaker protection, none.
  • Find your state-changing GET routes. Grep the route table for GET handlers whose names contain delete, revoke, disable, logout, confirm, approve or unsubscribe. Every one of them is row A3, and no value of SameSite other than Strict closes it.
  • List every host on your registrable domain. Certificate transparency logs will do it — crt.sh/?q=%25.your-domain.example — and so will your DNS zone. Each name is a page that can send requests carrying your Strict cookie. If any of them serve content you do not review, row A5 is live in your estate today.
  • Test the exemption list. Whatever your framework's CSRF middleware is, print the routes it skips. In most codebases that list was written once, to unbreak a webhook, and never revisited.
  • Check the token is bound to the session. Sign in as two users, take the token from one and submit it in the other's request. If it is accepted, you have the naive double-submit and A5 is open regardless of the cookie attribute.
  • Watch the login window. Chrome's DevTools Network panel shows a blocked cookie with a reason. Sign in, then within two minutes trigger a cross-site top-level POST at your own app from a scratch page on another origin, and see whether the cookie rides along. That is the Lax-allowing-unsafe window, and it is the difference between the default and the attribute.

Your session cookie is SameSite=Strict. Your CSRF middleware checks a token bound to the session on POST, PUT and DELETE, and exempts GET. Which of these still reaches a handler with the user's session?

The cookie that all of this protects is a session identifier, and how it is issued matters as much as how it travels — see why the identifier has to change at the moment of login. And when there is no cookie at all, the same forged-request problem reappears in a different shape: see what a bearer token does and does not carry.

Why this concept is on the site

Topics are chosen from places engineers visibly get stuck, and the sources are kept with the lesson so the claim is checkable.