Most cookie bugs we have debugged look the same from the outside: a user is "randomly logged out",
an embedded widget forgets who you are, or an SSO callback lands on a login page again. Underneath,
almost all of them come down to six attributes on one Set-Cookie header and the way browsers apply
them to cross-site requests. This guide is for backend and frontend engineers who set cookies and
want sensible defaults instead of copying flags from a forum answer.
By the end you should be able to pick the right attributes for a first-party session, an SSO flow, and an iframe embedded on other sites, and to explain the choice in a security review.
The two words that matter: site and origin
An origin is scheme plus host plus port: https://app.example.com:443. A site is the scheme
plus the registrable domain: https://example.com. app.example.com and api.example.com are
different origins but the same site. example.com and example.net are different sites.
CORS and most browser security rules work on origins. SameSite works on sites. That is why a
request from app.example.com to api.example.com is "same-site" and carries even SameSite=Strict
cookies, while the same call from partner.example.org is cross-site. Registrable domains come from
the Public Suffix List, so alice.github.io and bob.github.io are different sites even though they
share a parent domain.
SameSite: when the browser attaches the cookie
SameSite controls whether a cookie is sent on requests initiated from another site.
| Value | Sent on same-site requests | Sent on cross-site top-level navigation (link click, GET) | Sent on cross-site subresources, iframes, fetch, POST |
|---|---|---|---|
Strict |
yes | no | no |
Lax |
yes | yes, for safe methods like GET | no |
None (requires Secure) |
yes | yes | yes |
Chrome and Edge treat a cookie without a SameSite attribute as Lax. Firefox and Safari have their
own default behaviours and tracking protections, so always set the attribute explicitly rather than
relying on any browser default.
How to choose:
- Session cookie for a normal web app:
Lax. Users who click a link to your app from email or Slack arrive logged in, while cross-site POSTs and embedded requests do not carry the cookie, which blocks most CSRF. - High-value actions:
Stricton a separate cookie, for example one that gates account deletion or payment changes. Users coming from another site must make one more same-site request before it is sent, which is acceptable for those screens. - Cookies that must work inside an iframe or a cross-site fetch:
None; Secure, and read the sections on Partitioned and third-party cookies below, becauseNonealone no longer guarantees anything.
A classic SSO bug: the identity provider returns the user with a cross-site POST (the
form_post response mode). A Lax session or state cookie is not sent on that POST, the app cannot
match the state, and the login loops. Fix it by marking only the short-lived state cookie as
SameSite=None; Secure, or by switching the response mode to a redirect with query parameters.
Secure and HttpOnly
Secure means the cookie is only sent over HTTPS (and on http://localhost in modern browsers, for
development). It also blocks insecure pages from overwriting a Secure cookie. There is no good reason
to omit it on a production site served over HTTPS.
HttpOnly hides the cookie from document.cookie and other script APIs. It does not prevent the
browser from sending it, so it does not stop CSRF, but it stops a cross-site scripting bug from simply
reading the session token and sending it elsewhere. Session and refresh token cookies should always be
HttpOnly. The usual exception is a CSRF token cookie that your JavaScript must read and echo back in
a header, such as the XSRF-TOKEN convention in Laravel and Angular.
Domain, Path and the cookie prefixes
Without a Domain attribute, a cookie is host-only: it is sent only to the exact host that set it.
Adding Domain=example.com makes it available to every subdomain, including old marketing sites and
anything a vendor hosts under your domain. Prefer host-only cookies unless you genuinely share a
session across subdomains.
The cookie name prefixes let the browser enforce these rules for you:
Set-Cookie: __Host-session=s_7d0c1e; Path=/; Secure; HttpOnly; SameSite=Lax
Set-Cookie: __Secure-prefs=theme-dark; Domain=example.com; Path=/; Secure; SameSite=Lax
__Secure-requires theSecureattribute and an HTTPS origin.__Host-requiresSecure, requiresPath=/, and forbidsDomain. A subdomain therefore cannot plant or overwrite a__Host-cookie for your main host, which closes "cookie tossing" attacks where a compromised subdomain fixes a session cookie for the parent.
If the browser sees a prefixed cookie that does not meet the rules, it rejects it. That is the point:
misconfiguration fails loudly in DevTools instead of silently weakening the cookie. We use __Host-
for every session cookie on new projects.
Max-Age, Expires and session cookies
A cookie with neither Max-Age nor Expires is a session cookie and is dropped when the browser
session ends, although "continue where you left off" settings can keep browser sessions alive for
days. If you rely on expiry for security, enforce it on the server: store an expiry with the session
record and reject old sessions regardless of what the browser kept. Chrome also caps the lifetime of
cookies to 400 days, in line with the current cookie specification draft.
Partitioned cookies (CHIPS)
Embedded content is where most cookie pain lives in 2025. A support chat widget served from
widget.example-chat.com and embedded on shop.example.com needs to remember its conversation.
In a cross-site iframe, its cookies are third-party cookies.
Cookies Having Independent Partitioned State (CHIPS) adds a Partitioned attribute. A partitioned
cookie is stored with a second key, the top-level site where it was set:
Set-Cookie: __Host-chat_sid=c_51ab; Path=/; Secure; HttpOnly; SameSite=None; Partitioned
The widget on shop.example.com gets one cookie jar; the same widget on news.example.org gets a
different, unrelated one. The widget works, but it cannot follow the user across sites, which is the
privacy property that browsers are protecting. Partitioned requires Secure, and pairing it with
__Host- is recommended.
Support status as of mid-2025: Chromium-based browsers have supported partitioned cookies since
Chrome 114. Firefox and Safari already partition or block third-party cookies by default through their
own tracking protections, and their handling of the Partitioned attribute itself has been
evolving, so test in each browser rather than assuming identical behaviour. For our chat widget, a
partitioned cookie plus a server-side session keyed by the embedding site worked across all three.
Partitioning is the right model for state that only makes sense per embedding site: chat conversations, embedded maps, payment iframes within one checkout. It is the wrong model for cross-site single sign-on, which needs a top-level redirect or a federated identity API instead.
Where third-party cookies stand in 2025
The situation changed several times, so here is the short version for planning:
- Safari blocks third-party cookies by default under Intelligent Tracking Prevention.
- Firefox isolates cookies per top-level site by default with Total Cookie Protection.
- Chrome announced in April 2025 that it will keep its current approach of letting users choose in settings, and will not roll out a new standalone prompt. Third-party cookies remain available by default in regular Chrome windows, but Incognito blocks them, and many users turn them off.
In practice, a design that depends on unpartitioned third-party cookies already fails for a large share of users. Build for partitioned state or top-level first-party flows, and treat a working third-party cookie as a bonus.
Tested defaults
# First-party session (web app on its own domain)
Set-Cookie: __Host-sid=<opaque>; Path=/; Secure; HttpOnly; SameSite=Lax
# CSRF token readable by JS (double submit pattern)
Set-Cookie: __Host-xsrf=<random>; Path=/; Secure; SameSite=Strict
# Short-lived OAuth/OIDC state for form_post callbacks
Set-Cookie: __Host-oidc_state=<random>; Path=/; Secure; HttpOnly; SameSite=None; Max-Age=600
# Widget embedded on customer sites
Set-Cookie: __Host-widget_sid=<opaque>; Path=/; Secure; HttpOnly; SameSite=None; Partitioned
To check what the browser actually stored, open DevTools, go to Application > Cookies in
Chromium (Storage in Firefox, Storage in Safari's Web Inspector). Chromium shows a warning icon next
to rejected cookies with the reason, for example an invalid prefix or SameSite=None without Secure.
From the command line you can see exactly what the server sends:
curl -sI https://app.example.com/login | grep -i '^set-cookie'
Common mistakes
| Mistake | Why it hurts | Do this instead |
|---|---|---|
SameSite=None without Secure |
Browser rejects the cookie entirely | Always pair None with Secure |
Domain=example.com on session cookies |
Every subdomain receives and can overwrite the session | Host-only cookies, __Host- prefix |
| Relying on SameSite as the only CSRF defence | Same-site subdomains and GET side effects bypass it | CSRF tokens on state-changing requests too |
| Session cookie readable by JavaScript | XSS can steal the session | HttpOnly on session and refresh tokens |
| Assuming third-party cookies work in iframes | Safari and Firefox block or partition them | Partitioned cookies or top-level flows |
| Trusting cookie expiry for security | Browsers restore sessions and users copy cookies | Enforce session expiry on the server |
Key takeaways
- SameSite is about sites, not origins; subdomains of your registrable domain are same-site.
- Default to
__Host-plusSecure,HttpOnlyandSameSite=Laxfor sessions. - Use
SameSite=Noneonly for cookies that must cross sites, and expect it not to be enough on its own. - For embedded widgets, use
Partitionedcookies and design for per-site state. - Keep CSRF tokens and server-side session expiry; cookie attributes are a layer, not the whole defence.
References
- MDN: Set-Cookie header — https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Set-Cookie
- MDN: Cookies Having Independent Partitioned State (CHIPS) — https://developer.mozilla.org/en-US/docs/Web/Privacy/Guides/Privacy_sandbox/Partitioned_cookies
- IETF draft: Cookies: HTTP State Management Mechanism (RFC 6265bis) — https://datatracker.ietf.org/doc/draft-ietf-httpbis-rfc6265bis/
- Privacy Sandbox: Next steps for Privacy Sandbox and tracking protections in Chrome — https://privacysandbox.google.com/blog/privacy-sandbox-next-steps
- WebKit: Full Third-Party Cookie Blocking and More — https://webkit.org/blog/10218/full-third-party-cookie-blocking-and-more/
- Mozilla: Total Cookie Protection — https://blog.mozilla.org/en/mozilla/firefox-rolls-out-total-cookie-protection-by-default-to-all-users-worldwide/
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.