Skip to main content
Web

HTTP cookie attributes: SameSite, Secure, HttpOnly, and the __Host- prefix that makes them stick

Which Set-Cookie attributes actually protect a session cookie, which ones people think do but don't, and the exact header worth copying for a login cookie.

Thien
By Thien
Updated October 17, 2026 · 6 min read
glass jars filled with cookies displayed on a white table

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.

AttributeStopsDoes not stop
SecureThe cookie being sent over http://Anyone who can read the cookie on the client
HttpOnlydocument.cookie reading itXSS using your session (the browser still attaches the cookie to requests the injected script makes)
SameSite=LaxCookie sent on cross-site subresource requests and cross-site POSTsCross-site top-level GET navigations, and attacks from a sibling subdomain
SameSite=StrictCookie sent on any cross-site request, including link clicksSame-site attacks (it's the site that matters, not the origin)
Domain=example.comNothing. It widens scope to every subdomainCompromise of any subdomain
Max-Age / ExpiresCookies living foreverTheft 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 a fetch() POST to /api/transfer and 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.com calling an API on api.example.com sends SameSite=Strict cookies just fine. You don't need None for that.
  • user1.github.io and user2.github.io are cross-site, because github.io is on the Public Suffix List. A cookie scoped to github.io would 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. Set SameSite explicitly and the grace period goes away.
  • Firefox and Safari did not adopt the Lax default 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=/; Partitioned

The prefix trick

A cookie name starting with __Host- or __Secure- is a contract the browser enforces:

PrefixThe 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

  1. Setting Domain "to be safe". Omitting Domain makes the cookie host-only, which is the narrow option. Setting it makes the cookie visible to every subdomain, including the one you forgot about.
  2. Treating SameSite as a full CSRF defence. Lax blocks the common attack (hidden cross-site form POST), but state-changing GET endpoints are still reachable by link click, and sibling-subdomain attacks bypass it entirely. Keep CSRF tokens or an Origin header check on anything that mutates state.
  3. Storing a JWT in localStorage to "avoid CSRF". It trades one problem for XSS-readable tokens. The storage comparison goes through when each store is the right call.
  4. 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 get 431 Request Header Fields Too Large from nginx or Node, which looks nothing like a cookie problem.
  5. Assuming a long Max-Age sticks. Chrome caps cookie lifetime at 400 days regardless of what you send.

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).

It makes it safe from being read by XSS, not from being used. Fix the XSS.

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.

References

Primary documentation and specifications checked when this article was last updated.

WebSecurityHTTP

Related articles

All articles