Cookies — who, when, and where they get sent automatically
Cookies are tricky because the browser attaches them automatically without the developer sending them explicitly. This auto-send is what lets login persist — and it's also why CSRF succeeds.
And attribute meanings are easy to invert. Many think Domain narrows the scope; in fact, it widens.
What you'll learn in this chapter
- Which of "where to send", "how long to keep", and "who can read" each attribute decides
- Cookie scope doesn't match origin — it ignores scheme and port
- The three values of
SameSiteand why judgment is at the site level - Triaging "login expires" by attribute
Builds on HTTP headers and statelessness from HTTP. Cookies work on the paired headers Set-Cookie and Cookie.
What this chapter does not cover
| Topic | Where it lives |
|---|---|
| Comparing cookies with the other storage mechanisms | Browser storage |
| Token storage and refresh flows in an SPA | React Guide — SPA authentication |
Whether SameSite alone stops CSRF | Common web attacks and defenses |
What cookies are for
HTTP is stateless. The server doesn't remember state between requests, so continuing information like login has to be carried on every request.
Two ways to carry it:
| Method | Who attaches |
|---|---|
Authorization header | Application code attaches explicitly |
| Cookies | Browser attaches automatically when conditions are met |
This difference underpins everything that follows. Auto-send is convenient, but it also attaches to requests the developer didn't intend.
Server hands them out; browser sends them back
HTTP/1.1 200 OK
Set-Cookie: session_id=abc123; Path=/; Secure; HttpOnly; SameSite=Lax
The browser stores this and returns it on matching requests.
GET /mypage HTTP/1.1
Host: example.com
Cookie: session_id=abc123
Set-Cookie is one per header. For three cookies, write three Set-Cookie lines. The returning Cookie header, on the other hand, is one line joining name=value pairs with ; .
Group attributes by role
Attributes answer three questions.
1. Where to send
| Attribute | Meaning |
|---|---|
Domain | Target host. Specifying widens to subdomains |
Path | Target path. Prefix-match |
SameSite | Whether to attach to requests from other sites |
Secure | Only send under HTTPS |
2. How long to keep
| Attribute | Meaning |
|---|---|
Expires | Absolute time |
Max-Age | Seconds. Takes precedence over Expires |
| (neither) | Session cookie — deleted when the browser closes |
To delete, send Max-Age=0. A past Expires does the same.
3. Who can read
| Attribute | Meaning |
|---|---|
HttpOnly | Not readable from JavaScript (doesn't appear in document.cookie) |
Partitioned | Treated as separate cookies per embedding site |
Scope is not origin
Here's the biggest misunderstanding. An origin is the triple of scheme + host + port, but cookie scope is decided by host + path — it doesn't look at scheme or port.
A cookie set on https://example.com:443
also attaches to requests to http://example.com:8080
Secure can constrain scheme. But there's no way to constrain port (draft-ietf-httpbis-rfc6265bis-21 takes the position of not treating ports as a security boundary). Cookies mixing across ports in local development is because of this.
Domain widens instead of narrowing
Set-Cookie: a=1 (no Domain)
Set-Cookie: b=2; Domain=example.com (with Domain)
| Cookie | To example.com | To app.example.com |
|---|---|---|
a (no spec) | Sends | Does not send |
b (Domain=example.com) | Sends | Sends |
Not specifying gives a narrower scope. Writing Domain targets that domain and all its subdomains.
Not writing it is safer unless there's a reason to widen. Write it only in cases like "share the same session across app.example.com and api.example.com".
SameSite — attach to requests from other sites?
| Value | Attached to cross-site requests? |
|---|---|
Strict | No |
Lax | Only for top-level navigation with a safe method |
None | Always. Secure is required |
None requires Secure by spec (the same draft's Section 5.7 states that if the same-site-flag is None and secure-only-flag is false, the cookie is ignored).
Evaluated at site level, not origin level
As "SameSite" suggests, judgment is at the site level. Site is decided by the registrable domain (eTLD+1).
| Navigation | Same site? |
|---|---|
app.example.com → api.example.com | Same (both example.com) |
example.com → example.co.jp | Different |
alice.github.io → bob.github.io | Different (github.io is a public suffix) |
So SameSite doesn't restrict navigation between subdomains. Within the same site, cookies flow even with Strict.
Strict degrades UX. Users arriving from external links get no cookies, so a logged-in user is treated as logged-out. Because of this, using Lax as the default and putting CSRF defenses elsewhere is practical. Whether SameSite alone can prevent CSRF is discussed in Common web attacks and defenses with OWASP's position.
Name prefixes declare constraints
Attributes don't come back on the Cookie header. What returns is only name=value. So the server can't tell later "was this cookie set with Secure".
Name prefixes solve this.
| Prefix | Constraint the browser demands |
|---|---|
__Secure- | Must have Secure |
__Host- | Must have Secure, no Domain, and Path=/ |
The browser refuses Set-Cookie that doesn't meet the conditions. Since the name comes back only if conditions were met, server-side verification isn't needed.
__Host- is the strongest constraint for authentication cookies. Prohibiting Domain structurally prevents overwrite attacks from subdomains.
Judging "does this cookie attach to this request"
Triaging "login expires"
Narrow attributes by symptom. Check top to bottom.
| Symptom | Suspect |
|---|---|
| Breaks on moving to a subdomain | Domain not set (only that host receives) |
| Works locally, breaks in production | Secure (prod is HTTPS, local is http) |
| Breaks only on specific pages | Path prefix-match failing |
| Breaks when arriving from external links | SameSite=Strict |
| Breaks when the browser closes | No Max-Age / Expires (session cookie) |
| Breaks inside an iframe | SameSite isn't None, or missing Partitioned |
The DevTools Application tab shows the actual attributes. Look at real attributes before guessing — it's faster.
Defaults for an authentication cookie
Start here when in doubt.
Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
| Choice | Reason |
|---|---|
__Host- | Prohibits Domain; prevents overwrite from subdomains |
Secure | Doesn't flow in plaintext |
HttpOnly | Unreadable from scripts even if XSS occurs |
SameSite=Lax | Not attached to cross-site POST; avoids Strict's UX cost |
Path=/ | Required by __Host-. Site-wide use |
Deviating from these needs a reason. To share across subdomains, dropping __Host- in favor of Domain — deciding what to give up.
Common misconceptions
"HttpOnly is XSS defense." — Isn't. It prevents theft of the cookie, but a running script can send requests on the browser's behalf. Cookies auto-attach, so even without reading the value, operations can be performed.
"Setting Domain narrows the target." — Reverse. Setting it widens to subdomains. To narrow, don't set it.
"Secure encrypts cookie contents." — Doesn't. It's the spec for "only send under HTTPS". The value itself is plaintext. Encryption is done by TLS.
"Cookies only go to the same origin." — Ignoring scheme and port, they go wider than origin.
"SameSite=Strict prevents CSRF." — Doesn't prevent attacks from within the same site. If a subdomain is compromised, cookies still flow under Strict.
Exercises
Q1. You returned Set-Cookie: token=x; Domain=example.com; Path=/api. Which of these attach the cookie?
A. https://example.com/api/orders
B. https://example.com/mypage
C. https://app.example.com/api/orders
D. http://example.com/api/orders
Answer and explanation
Answer: A, C, D
| Verdict | Reason | |
|---|---|---|
| A | Sends | Both host and path match |
| B | Does not send | Fails the Path=/api prefix |
| C | Sends | Domain targets subdomains too |
| D | Sends | Secure isn't set, so http also sends |
D is the trap. Domain and Path narrow, but scheme isn't narrowed unless Secure is set. A cookie named token flows in plaintext by design.
Q2. Cookies aren't sent on the SPA's API calls. You added SameSite=None; Secure and nothing changed. What to check?
Answer and explanation
Answer: Confirm the request side is sending credentials.
SameSite=None removes the browser-side restriction, but it isn't enough. fetch by default doesn't attach cookies for cross-origin requests; credentials: 'include' is needed.
The server side also needs to meet conditions:
| Needed | Reason |
|---|---|
Access-Control-Allow-Credentials: true | Permit credentialed requests |
Access-Control-Allow-Origin with a specific origin | * is unusable with credentials |
That is, cookie side, request side, and CORS side — all three must line up. See Origins and CORS for details.
Q3. app.example.com's admin panel was taken over. Session cookies use Domain=example.com. What happens?
Answer and explanation
Answer: example.com and all its subdomains have their sessions affected.
A cookie with Domain=example.com is readable and writable from app.example.com. Scripts running on the compromised subdomain can:
- Read the cookie (with
HttpOnlythey can't read it, but it auto-attaches to requests) - Overwrite a cookie of the same name (session fixation)
HttpOnly prevents reading but doesn't prevent overwriting. If the attacker writes a session ID they prepared, the victim will log in under that session.
__Host- structurally prevents this. Prohibiting Domain means subdomains can't touch the parent's cookies. The cost is that sessions can't be shared across subdomains. If sharing is required, design the subdomain trust boundaries separately.
Summary
- Cookies are auto-sent by the browser. That auto-send sustains login and simultaneously enables CSRF
- Scope is not origin. Decided by host + path; scheme and port aren't looked at
- Setting
Domainwidens to subdomains. To narrow, don't set it SameSitejudgment is at the site (eTLD+1) level. It doesn't restrict cross-subdomain movesHttpOnlyprevents reading but doesn't prevent overwriting. Overwrite is prevented by__Host-- Attributes don't return in the
Cookieheader. That's why the name prefix declares the condition - Starting point for an auth cookie:
__Host-+Secure+HttpOnly+SameSite=Lax
- React Guide — SPA authentication — Refresh flow implementation
- MDN — Set-Cookie
- draft-ietf-httpbis-rfc6265bis-21 — Cookies — Latest draft defining
SameSiteand name prefixes. RFC 6265 (2011) doesn't includeSameSite - RFC 6265 — HTTP State Management Mechanism
- Public Suffix List — Public suffixes used in site judgment
What to read next
- Browser storage — Another place to put values, dividing on the opposite line from cookies
- Origins and CORS — Another boundary that doesn't match cookie scope
- Authentication and authorization — What the session approach carries in cookies
- Common web attacks and defenses — CSRF, which exploits auto-send, and the priorities of its defenses