Skip to main content

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 SameSite and why judgment is at the site level
  • Triaging "login expires" by attribute
Prerequisites

Builds on HTTP headers and statelessness from HTTP. Cookies work on the paired headers Set-Cookie and Cookie.

What this chapter does not cover​

TopicWhere it lives
Comparing cookies with the other storage mechanismsBrowser storage
Token storage and refresh flows in an SPAReact Guide — SPA authentication
Whether SameSite alone stops CSRFCommon 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:

MethodWho attaches
Authorization headerApplication code attaches explicitly
CookiesBrowser 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​

AttributeMeaning
DomainTarget host. Specifying widens to subdomains
PathTarget path. Prefix-match
SameSiteWhether to attach to requests from other sites
SecureOnly send under HTTPS

2. How long to keep​

AttributeMeaning
ExpiresAbsolute time
Max-AgeSeconds. 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​

AttributeMeaning
HttpOnlyNot readable from JavaScript (doesn't appear in document.cookie)
PartitionedTreated 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)
CookieTo example.comTo app.example.com
a (no spec)SendsDoes not send
b (Domain=example.com)SendsSends

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?​

ValueAttached to cross-site requests?
StrictNo
LaxOnly for top-level navigation with a safe method
NoneAlways. 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).

NavigationSame site?
app.example.com → api.example.comSame (both example.com)
example.com → example.co.jpDifferent
alice.github.io → bob.github.ioDifferent (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.

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

Triaging "login expires"​

Narrow attributes by symptom. Check top to bottom.

SymptomSuspect
Breaks on moving to a subdomainDomain not set (only that host receives)
Works locally, breaks in productionSecure (prod is HTTPS, local is http)
Breaks only on specific pagesPath prefix-match failing
Breaks when arriving from external linksSameSite=Strict
Breaks when the browser closesNo Max-Age / Expires (session cookie)
Breaks inside an iframeSameSite isn't None, or missing Partitioned

The DevTools Application tab shows the actual attributes. Look at real attributes before guessing — it's faster.

Start here when in doubt.

Set-Cookie: __Host-session=abc123; Secure; HttpOnly; SameSite=Lax; Path=/
ChoiceReason
__Host-Prohibits Domain; prevents overwrite from subdomains
SecureDoesn't flow in plaintext
HttpOnlyUnreadable from scripts even if XSS occurs
SameSite=LaxNot 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

VerdictReason
ASendsBoth host and path match
BDoes not sendFails the Path=/api prefix
CSendsDomain targets subdomains too
DSendsSecure 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:

NeededReason
Access-Control-Allow-Credentials: truePermit 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 HttpOnly they 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 Domain widens to subdomains. To narrow, don't set it
  • SameSite judgment is at the site (eTLD+1) level. It doesn't restrict cross-subdomain moves
  • HttpOnly prevents reading but doesn't prevent overwriting. Overwrite is prevented by __Host-
  • Attributes don't return in the Cookie header. That's why the name prefix declares the condition
  • Starting point for an auth cookie: __Host- + Secure + HttpOnly + SameSite=Lax
Related references