A cookie is just a name, a value and a set of attributes. The value is what your application cares about; the attributes are what the browser enforces. Miss one and the protection is gone for the entire lifetime of that cookie — silently, with nothing logged on your side.
What a Cookie Attribute Actually Does
When a response carries a Set-Cookie header, the browser stores the value and then applies the attributes on every subsequent request without asking the server again. The server never gets a second chance to weigh in: it cannot inspect the cookie jar, and it cannot tell whether the cookie it is receiving was sent over HTTPS or scraped out of document.cookie by injected JavaScript.
That makes the attributes the only enforcement point you get for free. They are also the cheapest security change in most stacks — a handful of characters in a header, with no change to the cookie value, the session store, or any application logic.
The Three That Matter Most
Secure
The cookie is only ever sent over HTTPS. Without it, a single downgraded request — an old bookmark, a hardcoded http:// link, a captive portal redirect — leaks the value in cleartext to anyone on the path. Secure is safe to add on any HTTPS-only site and has no functional side effects, which makes it the first attribute to roll out.
HttpOnly
JavaScript cannot read the value: document.cookie simply does not include it. This is what stops a cross-site scripting payload from exfiltrating a live session. It does not fix the XSS bug — an attacker with script execution can still act as the user inside the page — but it stops the session token from leaving the browser and being replayed later from somewhere else entirely.
SameSite
Controls whether the cookie is attached to requests originating from another site. It takes three values:
Strict— never sent on any cross-site request. The safest option, and the reason a user following a link from an email may land on your site logged out on the first click.Lax— sent on top-level navigations that use a safe method (a normal link click), but not on cross-sitePOSTrequests, iframes, orfetchcalls. This is the browser default when the attribute is absent.None— sent on every cross-site request. Required for embedded and federated flows, and rejected outright unlessSecureis also set.
SameSite=None whenSecure is absent. The cookie is dropped silently — no console error on most versions, nothing at all server-side — which usually surfaces as an intermittent logout rather than a failure you can trace.Reading a Set-Cookie Header
Here is a session cookie set correctly. Everything after the value is a constraint, and the order of the attributes does not matter:
HTTP/2 200
Set-Cookie: __Host-session=eyJhbGciOi...; Path=/; Secure; HttpOnly; SameSite=Strict; Max-Age=7200Read it back as a sentence: this cookie is called __Host-session, it applies to the whole site, it never travels over plain HTTP, scripts cannot see it, it is never attached to a cross-site request, and it expires two hours after it was issued.
The Same Cookie in Application Code
Express, and most frameworks built on it, take the attributes as an options object:
res.cookie('__Host-session', token, {
path: '/',
secure: true,
httpOnly: true,
sameSite: 'strict',
maxAge: 7200 * 1000,
});Adding Attributes at the Proxy
When the cookie is set by an upstream application you cannot easily change — a legacy service, a vendor appliance — nginx can rewrite the attributes on the way out. Treat this as a stopgap that buys time, not as the permanent fix:
proxy_cookie_flags ~ secure httponly samesite=strict;
# older nginx, or when you also need to rewrite the path
proxy_cookie_path / "/; Secure; HttpOnly";Scope: Domain and Path
The two scope attributes decide who receives the cookie, and both default to the safest possible answer if you leave them alone.
Omitting Domain produces a host-only cookie, sent to exactly the host that set it. Adding Domain=example.com widens it to every subdomain — including a marketing site on a third-party platform, a status page, or a forgotten staging host. Any one of those can then read a live session cookie. If you do not have a concrete reason to share the cookie across subdomains, do not set Domain at all.
Path is a much weaker boundary than it looks. It is a prefix match with no security value against script running on the same origin, because the same-origin policy does not partition by path. Use Path=/ and rely on the other attributes.
Lifetime: Max-Age and Expires
A cookie with neither attribute is a session cookie: it lives in memory and disappears when the browser closes — although "closes" is unreliable, since session restore in Chrome and Firefox routinely carries these across restarts.
Max-Age takes seconds and wins wherever both are present; Expires takes an absolute HTTP date and exists mainly for very old clients. The security question is the same either way: the window is how long a stolen value stays useful. Two hours for an authenticated session with a server-side idle timeout is a reasonable default. A year on an analytics cookie turns it into a persistent identifier, with the consent obligations that come attached.
Cookie Prefixes: __Host- and __Secure-
Prefixes move enforcement from your code into the browser. They are part of the cookie name, so nothing else has to change:
| Prefix | Browser refuses the cookie unless | Use for |
|---|---|---|
__Host- | Secure is set, Path=/, and no Domain | Session and CSRF cookies |
__Secure- | Secure is set | Cookies that must span subdomains |
The value of a prefix is that it fails loudly. If a future refactor drops Secure or bolts on a Domain attribute, the browser rejects the cookie outright and the login flow breaks in testing — instead of quietly continuing to work while being less safe than you think.
Browser Enforcement Today
Defaults differ enough between engines that a cookie which works in one browser can be dropped in another. Current behaviour:
| Browser | No SameSite attribute | None without Secure | Third-party cookies |
|---|---|---|---|
| Chrome | Treated as Lax | Rejected | Restricted, phasing out |
| Firefox | Treated as Lax | Rejected | Partitioned by default |
| Safari | Treated as Lax | Rejected | Blocked by ITP |
| Edge | Treated as Lax | Rejected | Restricted, phasing out |
Lax when the attribute is missing, so omitting it is not a vulnerability. It is still worth writing out: an explicit value documents intent, survives a change of default, and makes an audit of the header unambiguous.A Rollout Checklist
Work through these in order. Each step is independently shippable, and none of them requires a change to the cookie value itself.
- Inventory what you set. Load the app with an empty browser profile and record every
Set-Cookieheader, including the ones injected by third-party scripts. You cannot secure a cookie you did not know you were sending. - Add Secure everywhere. Safe on any HTTPS-only site, no functional side effects. Do it first to shrink the blast radius while you work on the rest.
- Add HttpOnly to anything sensitive. Check whether front-end code reads the value first. If a script genuinely needs the CSRF token, hand it over in the page body instead of loosening the cookie.
- Set SameSite deliberately. Default to
Lax, move session cookies toStrict, and reserveNonefor the specific embedded or federated flows that genuinely need it. - Adopt the prefixes. Rename session and CSRF cookies to
__Host-so the browser keeps enforcing the rules after you stop looking. - Re-audit on every release. Attributes drift as new dependencies land. A check that runs once at rollout tells you nothing about the state six months later.
Common Mistakes
- Setting
Domainon a session cookie, which shares it with every subdomain including ones you do not control. - Assuming
HttpOnlybreaks CSRF protection. It does not, as long as the token is also delivered in the response body. - Reaching for
SameSite=Noneas a blanket fix for one broken embed, rather than scoping it to the single cookie that needs it. - Long
Max-Agevalues on analytics cookies, which turn them into persistent identifiers with consent obligations attached. - Securing your own cookies and ignoring the ones set by tag managers, chat widgets and embedded players — they are on your domain and they are your responsibility.
Frequently Asked Questions
1. Do I need all three attributes on every cookie?
No. Session and CSRF cookies need Secure, HttpOnly and a deliberate SameSite value. A theme preference read by your own JavaScript cannot have HttpOnly by definition — it still gets Secure and SameSite=Lax. The rule is per-cookie, not per-site.
2. Does HttpOnly break my CSRF token?
Only if the token is delivered exclusively in a cookie that a script has to read. The standard pattern is to send the token in the page body (a meta tag or a hidden field) and keep the cookie copy HttpOnly, so the double-submit comparison still works and the value is never exposed to document.cookie.
3. What happens if I omit SameSite entirely?
Chrome, Firefox, Safari and Edge all treat a cookie with no SameSite attribute as SameSite=Lax. That default is safe, but it is not the same as declaring Lax — it hides your intent from anyone auditing the header, and it means the browser, not you, decides the behaviour.
4. Why is my SameSite=None cookie being dropped?
Because Secure is missing. Every current browser rejects SameSite=None outright when the cookie is not also marked Secure. Nothing is logged server-side — the cookie simply never arrives, which usually shows up as an intermittent logout rather than an error.
5. Should I set a Domain attribute on a session cookie?
Almost never. Omitting Domain produces a host-only cookie that is sent to exactly the host that set it. Adding Domain=example.com shares the cookie with every subdomain, including any you do not fully control — a single compromised or third-party-hosted subdomain can then read live sessions.
6. How do I check what my site is actually sending?
Run the domain through the Cookie Security Checker. It reads every Set-Cookie header a page returns and grades the Secure, HttpOnly, SameSite, prefix and lifetime coverage per cookie. In the browser, DevTools → Application → Cookies shows the same attributes as columns.