Cookies across origins: SameSite, Domain, and the third-party crackdown
How cookie scoping is the one place origin hierarchy actually exists — Domain vs host-only, site vs origin, SameSite Strict/Lax/None precisely, why browsers block third-party cookies on top of whatever SameSite says, CHIPS, and the durable fix for multitenant/white-label auth.
The previous lesson ended on one rule: CORS compares origins as exact strings, with no concept of subdomains or a parent domain. Cookies are about to break that rule on purpose — and the gap between "CORS has no hierarchy" and "cookies absolutely do" is where most cross-subdomain auth bugs live.
Anatomy of a cookie
Set-Cookie: authToken=eyJhbGci...; Domain=.example.com; Path=/; Max-Age=86400; Secure; HttpOnly; SameSite=Lax| Attribute | Controls |
|---|---|
Domain | Which hosts get this cookie sent back to — omit it and you get host-only scoping (below) |
Path | Which URL paths on that host get it — almost always / |
Expires / Max-Age | Cookie lifetime; omit both for a session cookie that dies when the browser closes |
Secure | Only sent over HTTPS |
HttpOnly | Invisible to JavaScript (document.cookie can't read it) — the defense against token theft via XSS |
SameSite | Whether it's sent on cross-site requests at all — Strict, Lax, or None |
Partitioned | The newer CHIPS attribute — storage keyed per top-level site instead of globally (below) |
HttpOnly and SameSite solve two different problems and neither substitutes for the other: HttpOnly stops a different vector — a malicious script running on your own origin (XSS) reading the cookie and exfiltrating it — while SameSite governs whether the cookie rides along on requests to other sites. A compliance requirement to keep HttpOnly doesn't touch the SameSite question at all; they're orthogonal, and a real deployment needs both.
Domain: the one place hierarchy actually exists
Omit Domain entirely and the cookie is host-only — sent back only to the exact host that set it, the closest cookies get to CORS's exact-match rule. But set Domain=.tprm.digital explicitly, and the cookie becomes valid for tprm.digital and every subdomain beneath it: api.cvt.tprm.digital, spark18.cvt.tprm.digital, and a nested a.b.spark18.cvt.tprm.digital all qualify, because Domain scoping matches by walking up the hostname toward the registrable domain, not by exact string. This is the mirror image of the CORS lesson's rule, and it's worth saying plainly: CORS never looks at your domain hierarchy; Domain-scoped cookies are built entirely around it. Two completely different browser subsystems, two completely different mental models, and mixing them up is exactly how "but I set the origin correctly" debugging sessions go nowhere.
One guardrail: browsers refuse a Domain set to a public suffix — you cannot set Domain=.com or Domain=.co.uk. The Public Suffix List is what makes that determination, and it's the same list that defines "registrable domain" for the next section — it exists precisely so cookie scoping (and SameSite) can never accidentally span two unrelated organizations that happen to share a shared-hosting suffix like github.io or vercel.app.
Site vs origin: the distinction SameSite actually runs on
Two terms that sound interchangeable and aren't:
- Origin — scheme + host + port, exact. (The CORS lesson's definition.)
- Site — the registrable domain (eTLD+1:
example.com,vendorstudio.live), ignoring scheme and port entirely.
gtdemo.vendorstudio.live and api.vendorstudio.live are different origins (different hosts) but the same site (they share the registrable domain vendorstudio.live). gtdemo.vendorstudio.live and api.demo.tprm.crest.digital are different origins and different sites — vendorstudio.live and crest.digital share nothing. SameSite cares about the site comparison, not the origin comparison. That single fact is the missed lever in the story above: had the API lived at api.vendorstudio.live instead of an unrelated registrable domain, a plain SameSite=Lax — not even None — would have been enough, because a same-site request isn't subject to SameSite restrictions at all, regardless of subdomain.
SameSite=Strict / Lax / None, precisely
| Request type | Strict | Lax (modern default) | None |
|---|---|---|---|
Same-site (any subdomain, per Domain/site rules) | Sent | Sent | Sent |
| Top-level navigation via link/typed URL, cross-site | Not sent | Sent | Sent |
Cross-site form POST navigation | Not sent | Not sent | Sent |
Cross-site fetch/XHR, <img>, any subresource | Not sent | Not sent | Sent — requires Secure |
Read that table as: Strict and Lax differ only in the one "safe," user-initiated, top-level GET navigation case (clicking a link into a logged-in-looking page). Everything genuinely automated and cross-site — a form auto-submitted by a script, an XHR, an image tag — is withheld by both Strict and Lax alike. Only None sends a cookie on those. And browsers now enforce SameSite=None requiring Secure as a hard rule — a None cookie over plain HTTP is simply dropped.
The multitenant API call from the story above is a cross-site XHR (different registrable domain), which is exactly the row Lax refuses — that's why the backend had to set SameSite=None at all. None is necessary for a genuinely cross-site cookie to work in principle. It is not, on its own, sufficient — which is the rest of this lesson.
Try the combinations. A cross-site fetch needs SameSite=None even to be spec-legal — but then tick “block third-party cookies” and watch it get vetoed anyway, the exact “works in my window, not Incognito” failure.
SameSite decides when a cookie rides along: Strict withholds it on all cross-site requests, Lax (the default) allows only top-level navigation, None sends it everywhere — but only over HTTPS (Secure). And there's a second gate: even a spec-legal None; Secure cookie can be vetoed by the browser's third-party-cookie policy — which is why “it works in my normal window but not Incognito” is such a common bug. A visible Set-Cookie header never proves the browser kept it.
Third-party cookie blocking: a separate, additional layer
Here's the part that catches people who've read the SameSite spec and assume that settles it: SameSite=None; Secure is the cookie telling the browser "please send me cross-site." The browser is not obligated to comply, and increasingly doesn't, by default, for cookies it classifies as third-party. This is a distinct policy layer sitting on top of SameSite, with its own history:
- Safari's Intelligent Tracking Prevention (ITP) has blocked third-party cookies by default for years,
SameSite=Noneor not. - Firefox's Enhanced Tracking Protection (ETP) does something similar via its own blocklist-driven approach.
- Chrome has been rolling out third-party cookie restrictions gradually — tied to per-profile settings, enrollment cohorts, and heuristic exemptions — rather than a single global switch.
- Every Chromium browser's Incognito/Private mode force-blocks third-party cookies unconditionally, regardless of what the same browser's normal-profile setting says. This is stated directly in Chrome's own settings UI, and it's exactly what happened in the story above: a normal Chrome profile with third-party cookies allowed, tested in an Incognito window, which blocks them anyway.
The practical rule: a cookie can be spec-legal (SameSite=None; Secure) and still never arrive, because the browser's separate storage-partitioning policy vetoes it before SameSite is even consulted. Seeing Set-Cookie in a login response's headers doesn't prove the browser stored it, either — DevTools will happily show you the raw header the server sent while the browser silently declines to persist it.
Reading Sec-Fetch-Storage-Access instead of guessing
Modern Chromium sends a Sec-Fetch-Storage-Access request header alongside cross-site requests, with one of three values: none (this context has no access to unpartitioned storage for this pairing, and hasn't asked), inactive (it could ask, via the Storage Access API, but hasn't), or active (it asked and was granted). Seeing none on the failing request, next to an entirely absent Cookie header, is a direct, first-party signal that the browser's third-party policy — not your server, not a race condition, not a subdomain-scoping bug in your cookie code — is what dropped it. That one header turns "why is this 401ing" from a guessing game into a two-minute diagnosis.
CHIPS: Partitioned cookies
Partitioned is a newer Set-Cookie attribute — CHIPS, "Cookies Having Independent Partitioned State" — built for exactly the legitimate cross-site case: a service (an API, a widget, an analytics embed) that's genuinely embedded across many unrelated top-level sites and needs to remember state per top-level site without that state being usable to track a user across sites.
Set-Cookie: authToken=...; Secure; SameSite=None; Partitioned; HttpOnlyWith Partitioned, the browser keys the cookie's storage by the top-level site making the request — so it's sent back reliably for repeat requests from the same top-level site (a tenant's frontend calling its own API, session after session), without being treated as a cross-site tracking mechanism the browser needs to block. It preserves HttpOnly and Secure untouched — no new exposure to XSS, no change to framing or CSP, a pure storage-keying change.
The limit: CHIPS is Chromium-only today. Safari's ITP doesn't recognize the attribute and keeps blocking regardless — so Partitioned closes the Chrome gap but leaves Safari (and any non-Chromium browser without support) exactly where it was.
The durable fix: make the API first-party
The complete fix for multitenant or white-labeled auth isn't a cookie attribute at all — it's removing the cross-site relationship entirely. Route each tenant's (or reseller's) frontend and API through the same registrable domain, so from the browser's point of view there is only ever one first-party site:
- Same product, subdomain-per-tenant: put the API at
api.yourdomain.comrather than a separate registrable domain, so every tenant frontend and the API share one site —SameSite=Lax(or evenStrict, if no cross-site navigation into the app is needed) becomes sufficient, and third-party cookie policy never applies at all. - White-labeled custom domains: have the reseller's DNS
CNAMEtheir domain to your edge, and terminate TLS for that exact hostname there (a wildcard cert or per-tenant ACM certificate with SNI), forwarding to your origin. The browser is now genuinely talking to a single first-party site for the whole session — this works identically in Chrome, Safari, Firefox, and Incognito, because there's no cross-site relationship left to police. This is the same pattern behind "custom domain" support in checkout providers and identity platforms generally: the provider's infrastructure becomes reachable as the customer's own domain, rather than the customer's frontend reaching across to the provider's domain.
Cookies and CSRF: the piece SameSite quietly buys you
The CORS lesson ended on this: CORS controls whether JS can read a response, not whether a cross-site request gets sent. SameSite=Strict/Lax is the modern, browser-native answer to that gap — it's simultaneously the standard CSRF defense, because it withholds the ambient session cookie from exactly the cross-site, automated requests a CSRF attack relies on (a form auto-submitted from evil.com, an attacker-hosted image tag, a scripted fetch). Moving to SameSite=None to solve the third-party-cookie problem reopens that door — the cookie now rides along on cross-site requests again, by design, which is the entire point of None.
An app forced into SameSite=None (any genuinely cross-site multitenant/white-label setup) has to compensate at the application layer: a CSRF token or double-submit cookie, checked server-side on every state-changing request. There's a neat overlap with the CORS lesson here too — a common way to implement that check is requiring a custom header (e.g. a token echoed back as X-CSRF-Token), and a custom header is exactly what forces a CORS preflight in the first place. Once you require it, your CORS allowlist stops being a convenience and becomes a real access-control gate: only an origin your server explicitly trusts can even get past the preflight to attempt the state-changing call at all.
Checklist for multitenant cookie architecture
- Design the API to be same-site with every tenant frontend wherever possible — one shared registrable domain means
SameSite=Lax/Strictsuffices and third-party cookie policy never enters the picture. - Where a customer's own custom domain is genuinely unavoidable, proxy it to first-party via CNAME + edge TLS termination — that's the fix that survives every future tightening of browser policy, not just today's.
- If cross-site cookies are truly unavoidable short-term:
SameSite=None; Secure; Partitioned, plus an explicit CSRF token as the compensating controlSameSite=Nonegave up. - Never trust "it works in my normal browser" as proof of anything — test Incognito/Private explicitly; it's every browser's strictest, always-on mode, and it's a preview of where default behavior is headed for everyone.
- When something that should be a cookie problem shows up as a mystery 401, check the request's own headers for
Cookie(present or not) andSec-Fetch-Storage-Accessbefore suspecting the server at all.
Go deeper
- CORS: the browser's cross-origin contract — The twin lesson — exact-origin matching, preflights, and why CORS alone never stops CSRF.
- CDNs & the edge — The edge-termination mechanics (anycast, TLS at the edge) behind the CNAME-to-first-party pattern that fixes white-label cookie auth for good.
- CHIPS — Cookies Having Independent Partitioned State (Chrome for Developers) — The canonical spec-level reference for the Partitioned attribute, straight from the team shipping it.
Check yourself
Answer out loud, as if an interviewer asked. If you hand-wave, reread that section.
- A cookie has no Domain attribute set. Which hosts does the browser send it back to, and how is that different from setting Domain=.example.com?
- Two hosts are different origins but the same site. Name a pair, and explain why SameSite=Lax doesn't restrict a request between them at all.
- Walk through the SameSite=Strict / Lax / None table for a cross-site XHR specifically — which values send the cookie and which don't?
- A cookie is SameSite=None; Secure and still doesn't arrive on a cross-site request. What layer besides SameSite could be responsible, and what one request header would confirm it?
- What does the Partitioned attribute actually change about a cookie, and why does it fix this in Chrome but not Safari?
- Why does moving a cookie to SameSite=None re-open a CSRF hole that SameSite=Lax had closed, and what has to be added to compensate?