SameSite in Depth
Lax, Strict and None — what 'same site' actually means, and why it is not the same as 'same origin'.
SameSite decides whether the browser attaches a cookie to a request that was initiated by a different site. It is the browser-level defence against CSRF, and it is the attribute people get wrong most often.
Site ≠ origin
An origin is scheme + host + port. A site is the registrable domain (eTLD+1). So:
app.abc.comandapi.abc.com— different origins, same site. Cookies flow withLax.abc.comanddfg.com— different sites. OnlySameSite=Nonecookies are attached.
That distinction is the entire reason moving your API from dfg.com to api.abc.com changes what is possible.
The three values
| Value | Cross-site GET navigation (clicking a link) | Cross-site fetch / POST | Use for |
|---|---|---|---|
Strict | ❌ not sent | ❌ not sent | high-value actions, refresh tokens |
Lax (default) | ✅ sent | ❌ not sent | ordinary first-party sessions |
None | ✅ sent | ✅ sent (Secure required) | genuine cross-site APIs, embeds |
Lax is the modern default
Browsers now treat a cookie with no SameSite attribute as Lax. That silently broke a lot of cross-site integrations — if a cookie "stopped working in production", this is usually why. The practical effect of Lax is excellent: a cross-site <form method="POST"> or fetch gets no cookie, which kills the classic CSRF attack, while a user clicking a link to your site still arrives logged in.
The Strict annoyance
Strict means arriving from an external link — an email, a search result — shows you logged out, until you navigate once more. Some sites solve this with two cookies: a Lax read-only session and a Strict one required for state-changing operations.
None is on borrowed time
SameSite=None cookies are third-party cookies in every sense browsers care about. Safari's ITP has blocked them for years, Firefox partitions them, and Chrome has been phasing them down. A cross-site cookie architecture that works today may simply stop working for a slice of your users. Plan accordingly — the cross-origin category covers what to do instead.
Example
# Same site, different origin — Lax cookies ARE sent
https://app.abc.com → https://api.abc.com ✅ (site = abc.com)
# Different site — Lax cookies are NOT sent
https://abc.com → https://dfg.com ❌ (needs SameSite=None)
# Different scheme counts as cross-site for cookies in modern browsers
http://abc.com → https://abc.com ⚠️
# What Lax still allows (top-level GET navigation)
user clicks a link on google.com → https://abc.com/dashboard ✅ logged in
# What Lax blocks (the CSRF case)
evil.com runs: fetch('https://abc.com/api/transfer', {method:'POST',
credentials:'include'}) ❌ no cookieWhen to use it
- An embedded widget on customer sites needs SameSite=None to keep its session, and the team accepts that Safari users will be logged out unless they move to a token.
- A bank marks its session cookie Strict, so a link from an email lands on the login page — a deliberate trade of convenience for safety.
- A team splits the session into a Lax read cookie and a Strict write cookie so external links still show a logged-in dashboard while transfers require a same-site navigation.
More examples
Choosing the value from your deployment
If you find yourself reaching for sameSite: 'none', stop and ask whether the API could live on a subdomain of the frontend instead — that one change removes the whole problem.
// Frontend and API on the same site (abc.com + api.abc.com)
const cookieOptions = {
httpOnly: true, secure: true, sameSite: 'lax', path: '/',
// No domain → host-only. Add domain: '.abc.com' ONLY if the cookie must be
// readable by both app.abc.com and api.abc.com.
};
// Frontend and API on genuinely different sites (abc.com → dfg.com)
const crossSiteCookieOptions = {
httpOnly: true,
secure: true, // mandatory with sameSite: 'none'
sameSite: 'none', // ...and blocked by Safari / partitioned by Firefox
path: '/',
};
// The refresh token: strictest possible, narrowest path
const refreshCookieOptions = {
httpOnly: true, secure: true, sameSite: 'strict', path: '/auth/refresh',
maxAge: 30 * 24 * 60 * 60 * 1000,
};Reproducing the block yourself
Run this against your own API in a local environment: watching the request go out with no Cookie header is far more convincing than reading that Lax blocks it.
<!-- Serve this from http://evil.localhost and point it at your dev API. -->
<h1>CSRF probe</h1>
<!-- Classic form CSRF: blocked by SameSite=Lax, since it is a cross-site POST -->
<form action="https://dfg.com/api/transfer" method="POST">
<input name="to" value="attacker">
<input name="amount" value="1000">
<button>Send</button>
</form>
<!-- fetch with credentials: also blocked by Lax, and by CORS unless the API
explicitly allows this origin with credentials -->
<script>
fetch('https://dfg.com/api/transfer', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ to: 'attacker', amount: 1000 }),
}).then(r => console.log('status', r.status));
</script>
Discussion