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.com and api.abc.comdifferent origins, same site. Cookies flow with Lax.
  • abc.com and dfg.comdifferent sites. Only SameSite=None cookies 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

ValueCross-site GET navigation
(clicking a link)
Cross-site fetch / POSTUse for
Strict❌ not sent❌ not senthigh-value actions, refresh tokens
Lax (default)✅ sent❌ not sentordinary 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

Example · bash
# 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 cookie

When 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.

Example · javascript
// 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.

Example · html
<!-- 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

  • Be the first to comment on this lesson.