Security / security / browser / cookies
Lax by Default Is One Browser's Policy, Not the Web's
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.
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.
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
LaxorDefault; - 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
Origincheck on unsafe routes only. Both reach zero, and both reach it only when the cookie isSameSite=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 noSameSitevalue short ofStrictwithholds 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/loginand look at everySet-Cookieline. A line with noSameSite=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
SameSiteother thanStrictcloses 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 yourStrictcookie. 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.