For a session cookie, send Set-Cookie: __Host-sid=<token>; Secure; HttpOnly; SameSite=Lax; Path=/ and no Domain attribute. Secure keeps it off plain HTTP, HttpOnly hides it from document.cookie, SameSite=Lax stops it riding along on cross-site POSTs, and the __Host- prefix makes the browser reject the cookie if any of those guarantees is missing. Everything below is why each piece is there, and what it does not protect you from.
What each attribute really does
Most cookie advice reads like a checklist. It's more useful as a list of threats, because each attribute closes exactly one hole and leaves the others open.
| Attribute | Stops | Does not stop |
|---|---|---|
Secure | The cookie being sent over http:// | Anyone who can read the cookie on the client |
HttpOnly | document.cookie reading it | XSS using your session (the browser still attaches the cookie to requests the injected script makes) |
SameSite=Lax | Cookie sent on cross-site subresource requests and cross-site POSTs | Cross-site top-level GET navigations, and attacks from a sibling subdomain |
SameSite=Strict | Cookie sent on any cross-site request, including link clicks | Same-site attacks (it's the site that matters, not the origin) |
Domain=example.com | Nothing. It widens scope to every subdomain | Compromise of any subdomain |
Max-Age / Expires | Cookies living forever | Theft during the lifetime |
The row people get wrong most often is
HttpOnly. It protects the token from being exfiltrated, not from being used. An XSS payload can't read your session cookie, but it can fire afetch()POST to/api/transferand the browser will helpfully attach it.
"Same-site" is not "same-origin"
SameSite compares sites, not origins. A site is the scheme plus the registrable domain (the eTLD+1, resolved with the Public Suffix List). So app.example.com and api.example.com are same-site even though CORS treats them as different origins, and https://example.com versus http://example.com are cross-site because browsers use the scheme too ("schemeful same-site").
That has two consequences worth memorising:
- A frontend on
app.example.comcalling an API onapi.example.comsendsSameSite=Strictcookies just fine. You don't needNonefor that. user1.github.ioanduser2.github.ioare cross-site, becausegithub.iois on the Public Suffix List. A cookie scoped togithub.iowould not be possible at all.
If you're also fighting CORS errors on the same setup, the CORS explainer covers the other half: CORS decides who may read a response, SameSite decides whether the cookie is sent in the first place. For cross-origin requests that need cookies you need both, plus credentials: "include" on the fetch.
The defaults changed under you
Since Chrome 80 (Feb 2020, fully rolled out later that year), a cookie with no SameSite attribute is treated as Lax. Two details bite people:
- Chrome keeps a "Lax+POST" grace period: for cookies without an explicit
SameSite, a cross-site top-level POST within about two minutes of the cookie being set still carries it. This exists for SSO flows and masks bugs in testing. SetSameSiteexplicitly and the grace period goes away. - Firefox and Safari did not adopt the
Laxdefault the same way. Safari instead blocks third-party cookies outright; Firefox uses Total Cookie Protection to partition them. "Works in Chrome" tells you very little about cross-site cookies elsewhere.
If you genuinely need a cookie in a third-party context (an embedded widget, a payment iframe), the requirement is SameSite=None; Secure, and None without Secure is rejected by Chrome. Even then, third-party cookies are being phased out or blocked across browsers, so prefer Partitioned (CHIPS), which gives the embedded site one cookie jar per top-level site:
Set-Cookie: __Host-widget=abc123; Secure; HttpOnly; SameSite=None; Path=/; PartitionedThe prefix trick
A cookie name starting with __Host- or __Secure- is a contract the browser enforces:
| Prefix | The browser rejects the cookie unless |
|---|---|
__Secure- | Secure is set and the response came over HTTPS |
__Host- | Secure is set, Path=/, no Domain, and it came over HTTPS |
__Host- is the one worth using for sessions. Without it, a cookie set by evil.example.com (a forgotten staging box, a user-content subdomain) with Domain=example.com can overwrite or shadow your session cookie on www.example.com: the classic cookie tossing attack. __Host- cookies can only be set by the exact host, so a sibling subdomain can't plant one.
It also means that if you try to set __Host-sid with a Domain attribute, the browser silently drops it. No console error in most browsers, just a login that never sticks. If your cookie vanishes after adding the prefix, check the three requirements above first.
Common mistakes
- Setting
Domain"to be safe". OmittingDomainmakes the cookie host-only, which is the narrow option. Setting it makes the cookie visible to every subdomain, including the one you forgot about. - Treating
SameSiteas a full CSRF defence.Laxblocks the common attack (hidden cross-site form POST), but state-changingGETendpoints are still reachable by link click, and sibling-subdomain attacks bypass it entirely. Keep CSRF tokens or anOriginheader check on anything that mutates state. - Storing a JWT in
localStorageto "avoid CSRF". It trades one problem for XSS-readable tokens. The storage comparison goes through when each store is the right call. - Forgetting the size limit. Browsers cap a cookie at about 4096 bytes (name + value + attributes), and servers often cap the total
Cookie:request header at 8 KB. Past that you get431 Request Header Fields Too Largefrom nginx or Node, which looks nothing like a cookie problem. - Assuming a long
Max-Agesticks. Chrome caps cookie lifetime at 400 days regardless of what you send.
Debugging a cookie that isn't being set
When a login "doesn't stick", the reason is almost always one of the above rejecting the cookie quietly. Open DevTools → Application → Cookies, or the Network tab's response cookies view, which flags a rejected cookie with the reason (SameSiteNone requires Secure, invalid prefix, domain mismatch).
For a quick sanity check outside the browser, paste the raw Set-Cookie header into the Cookie Parser to see every attribute split out, or assemble one from scratch with the Cookie Builder so you don't misspell HttpOnly as HTTPOnly=true (browsers ignore the value, but copy-pasted junk like that hides real mistakes). Pair it with the CSP guide: HttpOnly limits what injected script can steal, CSP limits whether it runs at all, and you want both.
FAQ
Do I need SameSite=None for my SPA and API on different subdomains?
No. Subdomains of the same registrable domain are same-site, so Lax or even Strict cookies are sent. You still need CORS headers and credentials: "include" because they're different origins.
Is SameSite=Strict better than Lax for sessions?
Stricter, but it breaks the first click: following a link from email or Slack to your app arrives without the cookie, so the user looks logged out until they reload. Lax is the pragmatic default; use Strict for cookies guarding sensitive actions (a separate step-up cookie, for instance).
Does HttpOnly make my cookie safe from XSS?
It makes it safe from being read by XSS, not from being used. Fix the XSS.
Why does my cookie work on localhost but not in production?
Secure cookies are accepted on http://localhost by modern browsers, so you won't notice a Secure problem locally. In production the usual culprits are a TLS-terminating proxy that makes your app think it's on HTTP (so it doesn't set Secure, or the framework refuses to), or a __Host- prefix combined with a Domain attribute.
