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: Strict on 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, because None alone 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 the Secure attribute and an HTTPS origin.
  • __Host- requires Secure, requires Path=/, and forbids Domain. 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- plus Secure, HttpOnly and SameSite=Lax for sessions.
  • Use SameSite=None only for cookies that must cross sites, and expect it not to be enough on its own.
  • For embedded widgets, use Partitioned cookies and design for per-site state.
  • Keep CSRF tokens and server-side session expiry; cookie attributes are a layer, not the whole defence.

References