SameSite doesn't apply to this special guest: localhost

2024/09/19
networkhttpcookiesamesitesecurity

You log in at localhost:3000. Server sets a session cookie. You navigate to thembase.localhost:3000. Same machine. Just a different subdomain prefix. The cookie is gone. Server says you're not logged in.

Same machine. Same browser. Same port. Why?

Because the cookie only ever belonged to localhost, never to its subdomains. localhost is a special name, and the browser won't carry a cookie from it over to thembase.localhost. That's a scoping problem, not a SameSite one. Seeing why means untangling two rules people blur together: "same site" and "same origin."

The cookie I shipped

{
  httpOnly: true,
  secure: false,
  sameSite: "lax",
  path: "/",
}

sameSite: "lax" is the default in modern browsers. Sounds permissive. Actually has rules.

To understand the rules, you have to separate two concepts.

Same Site ≠ Same Origin

Different specs, different rules.

Same SiteSame Origin
Used bySameSite cookie attribute, Sec-Fetch-Site headerSame-Origin Policy (storage, fetch, postMessage)
CompareseTLD+1 (effective top-level domain + 1 label)scheme + host + port (all three exact)
www.example.com vs blog.example.com✅ Same site❌ Different origin
http://x.com vs https://x.com✅ Same site❌ Different origin
example.com:80 vs example.com:8080✅ Same site❌ Different origin

Same Site is broader than Same Origin. Same Origin requires everything to match. Same Site only cares about the registrable domain.

Schemeful caveat. Browsers later added schemeful same-site (Chrome 86+, now the default), which also splits on scheme. Under it, http://x.com and https://x.com count as cross-site after all. The cure is the same as everywhere else here: serve the whole site over HTTPS, and the scheme stops mattering. (Schemeful Same-Site)

What's eTLD+1

eTLD = effective Top-Level Domain. The "public" part of a domain. The part anyone can register under.

  • TLDs like .com, .org, .net are also eTLDs.
  • Multi-level eTLDs like .co.uk, .com.tw (you register mycorp.co.uk, not .uk).
  • eTLD+1 means eTLD plus one more label, which is the registrable name.

Examples:

DomaineTLDeTLD+1
www.example.com.comexample.com
blog.example.co.uk.co.ukexample.co.uk
cdn.assets.example.com.comexample.com

All *.example.com share example.com as eTLD+1, so all same site.

The Public Suffix List trap

Some hosting platforms run on a single domain but serve untrusted user content under subdomains. github.io, herokuapp.com, vercel.app. If youraccount.github.io and myaccount.github.io were treated as same site, your cookies would leak to mine.

The Public Suffix List fixes this. It declares certain domains as "public suffixes" that get treated like TLDs.

Once github.io is on the list:

  • github.io becomes an eTLD
  • youraccount.github.io is github.io + 1 label, so eTLD+1
  • myaccount.github.io is its own eTLD+1
  • They are different sites, even though they share github.io

This is why GitHub Pages users can't accidentally share cookies. Same mechanism for Heroku, Vercel, Netlify, S3 website endpoints. Anywhere user-generated subdomains exist.

SameSite values

Set-Cookie: id=abc; SameSite=Strict
Set-Cookie: id=abc; SameSite=Lax       (default in modern browsers)
Set-Cookie: id=abc; SameSite=None; Secure
ValueCross-site request sends cookie?Top-level navigation sends?
StrictNeverNo. Even clicking a link to your site won't send the cookie on first request
LaxNoYes. Clicking a link, address-bar typing, redirects all carry the cookie
NoneYes (everywhere)Yes (requires Secure flag)

Top-level navigation = the URL in the address bar changes. Clicking a link, typing a URL, redirects. NOT triggered by <img src>, <iframe>, <script>, fetch(), XHR. Those are sub-resource requests.

So Lax is the middle ground, and it splits on who started the request:

  • Cookie goes. You navigate to the site yourself: click an email link, type the URL, follow a redirect. The address bar changes, so it's a top-level navigation, and Lax allows it. The "click the link, still logged in" flow keeps working.
  • Cookie doesn't. Another page quietly calls your site: <img>, <iframe>, <script>, fetch. The address bar never changes, so it's a sub-resource request, and Lax blocks it. An attacker's <img src="https://yourbank.com/transfer?to=evil"> fires with no session cookie, and the CSRF fails.

Strict is paranoid: even the email link case fails. Reserve for high-stakes auth (banking second-factor, admin panels).

Back to the localhost bug

Recall: cookie set on localhost:3000, navigate to thembase.localhost:3000, cookie gone.

The instinct is to blame SameSite. It's the wrong suspect. The cookie never had a chance to reach the subdomain, and the reason is where it was scoped, not how it was guarded.

A cookie set on localhost with no Domain is host-only: bound to exactly localhost, never sent to thembase.localhost. Adding Domain=localhost doesn't rescue it either. localhost is a single-label, non-registrable name (RFC 6761, not on the Public Suffix List), so the browser refuses to widen the cookie across it. That's the same supercookie protection the Public Suffix List enforces everywhere else. The cookie stays pinned to one host.

I tested this across Chromium 114–149 and Firefox 151. On localhost the subdomain gets the cookie under no SameSite value, not Lax, not even None; Secure. Domain=localhost doesn't help; both browsers reject it and store the cookie host-only. Switch to a registrable name (myapp.test) and it just works under every value, because subdomains are same-site. The lever was never SameSite. It was the hostname.

Uninvite the special guest

The whole mess is localhost crashing the party with its own rules. Stop letting it host your cookies, give them a registrable home instead, and they behave exactly like production, subdomains and all. Three approaches, increasing radicality:

1. Use a registrable hostname in dev. .test is reserved for exactly this by RFC 6761, and unlike .local it won't collide with macOS mDNS. Point it at loopback in /etc/hosts:

# /etc/hosts
127.0.0.1   myapp.test
127.0.0.1   sub.myapp.test

Or append in one line:

echo "127.0.0.1 myapp.test sub.myapp.test" | sudo tee -a /etc/hosts

Now dev at http://myapp.test:3000 and set the cookie with Domain=myapp.test. The two hosts share myapp.test as eTLD+1, so the cookie spans both. This is the real fix.

2. Try Domain=.localhost (often won't work).

Set-Cookie: id=abc; Domain=.localhost

In theory this scopes the cookie to every subdomain of localhost. In practice Chrome and Firefox both reject it and store the cookie as host-only anyway, because localhost isn't registrable. The subdomain still gets nothing. Don't rely on it.

3. Use SameSite=None; Secure. Removes site-matching entirely, but requires HTTPS. Localhost is exempt from the HTTPS rule in most browsers, so secure: false works in dev. Don't ship this to production unless you actually need cross-site cookies (third-party iframes, embedded widgets).

The cleanest answer: a dev environment that mirrors production naming. myapp.test behaves like myapp.com. Cookie rules are identical, no special-casing.

Why SameSite=None requires Secure

If you go full open (SameSite=None), the cookie is sent on every cross-site request. Without Secure, an attacker on a coffee shop WiFi can MITM an HTTP request and steal it.

Browsers enforce: SameSite=None without Secure means cookie rejected.

Set-Cookie: id=abc; SameSite=None             rejected, no Secure
Set-Cookie: id=abc; SameSite=None; Secure     accepted, sent over HTTPS only

Chrome started enforcing this in 2020. Today it's universal.

What I now reach for

Set-Cookie: session=...; HttpOnly; Secure; SameSite=Lax; Path=/
  • HttpOnly: JS can't read it. XSS payload can't exfiltrate. Independent of SameSite, just always on for session cookies.
  • Secure: HTTPS only. Localhost exempt.
  • SameSite=Lax: default safe, allows email links to work.
  • Path=/: entire app.

Add Domain only when you actually need cross-subdomain cookies, and you've thought through the security implications.

What I learned

The frustrating part was assuming a subdomain should "just work." Sometimes it does. But localhost is special, and the Public Suffix List has trapdoors. What actually governs a cross-host cookie is the registrable domain: can this name even own a cookie that its subdomains share? localhost can't, so no SameSite value was ever going to save it.

Whenever cookies behave weirdly across hostnames, the first question is "what's the eTLD+1 of each side, and does the Public Suffix List intervene?" Not "do these look like the same site?"

References