Skip to content

5.6.3 — Cookies, In Full

HTTP is stateless. Every request is independent, and the server has no memory of the previous one. That is a deliberate design choice — it is why a server can be restarted, replaced or scaled to a hundred machines without anything breaking, and Chapter 10.2 shows how much of modern architecture that one property buys.

It also makes a login impossible. If the server forgets you between requests, how does the second page know you signed in on the first?

The answer, from Lou Montulli at Netscape in 1994, is a small piece of text the server hands the browser and the browser hands back on every subsequent request. That is a cookie, and it is simultaneously the mechanism behind every login you have ever used and the mechanism behind thirty years of tracking.

Why "cookie"?

From magic cookie, an existing Unix term for an opaque token passed between programs — you hand it back unchanged and it means something to whoever issued it. The fortune-cookie image, a small message you carry away with you, is a happy coincidence rather than the origin.

1. The mechanism, in two headers

http
# The server sets it
HTTP/1.1 200 OK
Set-Cookie: session=abc123; Max-Age=3600; Path=/; Secure; HttpOnly; SameSite=Lax

# The browser returns it on every matching request, automatically
GET /account HTTP/1.1
Cookie: session=abc123

Two headers. Set-Cookie goes down, one per cookie. Cookie comes back up, all matching cookies concatenated with ; .

The word to hold onto is automatically. The browser attaches matching cookies to every request without the page asking, without JavaScript being involved, and — critically — regardless of which page caused the request. Section 5 is entirely about the consequences of that last clause, because it is both the reason cookies work for logins and the reason CSRF exists.

2. Every attribute, and what it actually decides

Domain — who receives it

Omitted, the cookie is sent only to the exact host that set it. Set explicitly, it is sent to that domain and every subdomain.

Set-Cookie: a=1                          → only shop.example.com
Set-Cookie: b=2; Domain=example.com      → example.com AND api.example.com AND anything.example.com

There is no way to say "this subdomain and that one but not the other." It is either exactly here, or here and everything below.

This is the security decision people get wrong. A cookie on example.com is transmitted to every subdomain, including static.example.com where it is pure overhead on every image request, and including any subdomain you later give to a third party, a marketing tool, or a customer. If any subdomain is ever compromised or handed to someone else, they receive your session cookie on every request. This is why Chapter 5.5 flagged the apex-versus-www choice as a security decision and not an aesthetic one.

You cannot set a cookie for a domain you do not control. example.com cannot set a cookie on google.com, and — more subtly — cannot set one on co.uk. That second case needs a list, because the rule is not derivable from the name: co.uk is a registry suffix while example.com is a registrable domain, and there is no algorithm that tells you which. Browsers use the Public Suffix List, a hand-maintained file naming every suffix under which anyone may register. Without it, one site could set a cookie for all of .co.uk.

Path — which URLs receive it

Path=/admin means the cookie is sent for /admin and anything below it, and not for /.

Do not treat this as a security boundary. Path scoping is not enforced against JavaScript in any meaningful way — a script on the same origin can navigate or create requests that reach the path. It is a mild bandwidth optimisation and nothing more.

Expires and Max-Age — how long it lives

Neither attribute makes it a session cookie: it is held in memory and deleted when the browser closes. This is the right default for a login on a shared machine.

Max-Age=3600 is seconds from now, and it is the better one — it needs no clock agreement between server and browser.

Expires=Wed, 01 Oct 2026 12:00:00 GMT is an absolute date and depends on the browser's clock being correct. A device with a wrong clock either expires the cookie immediately or keeps it for years.

If both are present, Max-Age wins.

To delete a cookie, set it again with Max-Age=0 — and with exactly the same Domain and Path. A different scope creates a second cookie instead of deleting the first, and you end up with two cookies of the same name, of which the browser sends both and the server reads whichever comes first. This is a genuinely common bug during a logout.

"Session cookie" is an overloaded phrase worth pinning down. It can mean a cookie with no expiry, deleted on browser close (the technical meaning here), or the cookie holding your login session (the everyday meaning). Those are independent — a login cookie usually has an expiry and is therefore not a session cookie in the technical sense.

Secure — HTTPS only

The cookie is only ever sent over HTTPS. Without it, a single plain-HTTP request leaks the session token to anyone on the network. One image loaded over http:// on any page is enough.

Set it on every cookie that matters. There is essentially no reason not to.

HttpOnly — hidden from JavaScript

document.cookie cannot read it. This is the primary defence against session theft via XSS: if an attacker gets a script running on your page, they can do many bad things, but they cannot simply read the session token and post it to their own server.

It does not prevent XSS, and it does not stop the attacker using the session — a script can still make authenticated requests from the page, because the browser attaches the cookie automatically. HttpOnly stops exfiltration, not abuse. Stating that distinction precisely is what separates understanding from reciting.

Any cookie your JavaScript does not genuinely need should be HttpOnly.

SameSite — the CSRF defence

The most important attribute added in the last decade, and it needs section 5's context to make sense. In brief:

  • Strict — never sent on any cross-site request, including a plain link. Maximum safety; the visible cost is that following a link from another site lands you logged out, which looks broken.
  • Lax — sent on top-level navigations using safe methods (clicking a link), not on cross-site POSTs, images, iframes or fetch. This is now the default in every major browser, and it is a good balance.
  • None — always sent. Requires Secure. Necessary for genuine third-party use such as an embedded widget, and it is exactly what section 6 is about to switch off.

Partitioned — one jar per top-level site

Recent, and it is the migration path for third-party cookies. A partitioned cookie is stored separately for each top-level site that embeds it, so an embedded widget on news.com and the same widget on shop.com get different cookies and cannot be joined into a cross-site profile. Also called CHIPS (cookies having independent partitioned state). It preserves the legitimate embedded-widget use case while destroying the tracking one.

3. First-party and third-party, defined precisely

The distinction is not about who set the cookie. It is about which site is in the address bar.

You visit news.com. The page includes an ad from adnetwork.com. Two cookies exist:

  • news.com's cookie is first-party — the site you are visiting.
  • adnetwork.com's cookie is third-party — a different site than the one in the address bar.

The same cookie can be both. If you later visit adnetwork.com directly, its cookie is now first-party. The classification depends entirely on context, which is why the phrase "third-party cookie" describes a situation rather than a kind of cookie.

And here is the mechanism that made tracking possible. That ad network's script is embedded on ten thousand sites. Each time, the browser sends the same adnetwork.com cookie with the same identifier, along with a Referer header naming the page you are on. So the ad network accumulates:

user_9f3c  visited  news.com/politics
user_9f3c  visited  shop.com/pregnancy-tests
user_9f3c  visited  jobs.com/redundancy-advice

No individual site shared anything with anyone. The cross-site profile is assembled purely from one identifier being returned by a browser to one domain across many contexts. That is the entire technical basis of the behavioural advertising industry, and it is an emergent consequence of a 1994 design decision about which requests carry cookies.

4. Cookies versus the other storage

CookielocalStoragesessionStorageIndexedDB
Sent with requestsautomaticallynevernevernever
Size limit~4 KB~5–10 MB~5–10 MBlarge
Readable by JSunless HttpOnlyalwaysalwaysalways
Lifetimeattribute-controlleduntil cleareduntil tab closesuntil cleared
Sent to subdomainsif Domain setnonono

The 4 KB limit is a real constraint and it is why session cookies hold an opaque identifier rather than data. Note also that cookies are sent on every request to the matching scope — including every image, stylesheet and API call. Ten kilobytes of cookies on a page with 50 assets is 500 KB of pure upload overhead, on a connection whose upload is usually the slow direction. This is a measurable performance problem, and it is why static assets are often served from a cookie-free domain.

The recurring "should I store the JWT in localStorage or a cookie?" question has a real answer, and it is a trade rather than a winner:

  • localStorage is readable by any script, so an XSS gets the token immediately. It is not sent automatically, so CSRF is not possible.
  • A cookie with HttpOnly + Secure + SameSite cannot be read by script, so XSS cannot exfiltrate it. It is sent automatically, so CSRF must be defended against — which SameSite=Lax largely does.

The HttpOnly cookie is the better default, because XSS is more common than CSRF and its consequences are worse — a stolen token works from the attacker's own machine, indefinitely, whereas CSRF only works while the victim is on the attacker's page. Chapter 8.4.2 develops this properly.

5. CSRF: the attack that automatic sending creates

Cookies are attached to every request to their domain, including requests initiated by a completely different site. That is the whole vulnerability.

You are logged into bank.com. You visit evil.com, which contains:

html
<form action="https://bank.com/transfer" method="POST" id="f">
  <input name="to" value="attacker">
  <input name="amount" value="5000">
</form>
<script>document.getElementById('f').submit();</script>

The browser submits the form to bank.com and attaches your session cookie automatically, because cookies are scoped by destination, not by origin. The bank sees a properly authenticated request from a logged-in user and processes it.

The attacker never read your cookie, never saw the response, and does not need to. They only needed your browser to send a request on their behalf — which is called a confused deputy: an authorised party (your browser) is tricked into misusing its authority.

The layered defences, in the order you should apply them:

SameSite=Lax on session cookies. The cross-site POST above is not a top-level navigation, so the cookie is simply not sent. This is now the browser default and it eliminates the classic form-post attack outright.

A CSRF token. A random value placed in the page and required in the request body. The attacker's page cannot read it, because the same-origin policy stops evil.com reading bank.com's HTML. Either stored server-side per session, or the double-submit cookie pattern where the same random value is sent both as a cookie and in the body, and the server checks they match — which needs no server-side state.

Check the Origin header. Browsers set it on cross-origin requests and it cannot be forged by page script. Rejecting unexpected origins on state-changing requests is cheap and effective.

Never use GET for state changes (Chapter 5.6.1). A GET-based transfer is exploitable with a bare <img src="..."> tag, which no token can stop because the browser fetches images without any page cooperation.

6. Third-party cookies are being switched off

Every major browser now blocks third-party cookies by default or is moving to. Safari's Intelligent Tracking Prevention led in 2017, Firefox followed with Total Cookie Protection, and Chrome has been phasing them out over several years with repeated timeline changes.

What breaks is not only advertising, and this is the part worth knowing because it affects things you may build:

  • Federated single sign-on implementations that relied on a third-party cookie to detect an existing session at the identity provider (Chapter 8.4.6).
  • Embedded widgets — a comment system, a support chat, a payment iframe — that need to recognise a returning user.
  • Cross-domain analytics that stitched a user's path across several domains one company owns.

The replacements, honestly assessed:

  • Partitioned cookies (CHIPS) — keeps embedded widgets working while making cross-site joining impossible. The clean fix for the legitimate cases.
  • The Storage Access API — an embedded frame explicitly asks for permission to use its own cookies, and the user grants it. Preserves genuine use with consent.
  • First-party data collection — sites gathering their own data directly, which is why every site now wants your email address.
  • Server-side tagging and CNAME cloaking — routing a third party through a subdomain of the first-party site so its cookies count as first-party. This is an evasion rather than a solution, browsers are actively countering it, and it carries genuine legal exposure.
  • Chrome's Privacy Sandbox proposals — interest-based advertising computed in the browser without a per-user identifier leaving it. Technically interesting, commercially contested, still evolving.

The honest summary: cookies were designed for a single site to remember its own users, and they were used to build a cross-site surveillance system through a side effect of scoping. What is being removed is the side effect. First-party cookies — your logins, your preferences, your basket — are not going anywhere.

7. What to actually set

http
Set-Cookie: session=<opaque-random-value>;
            Max-Age=3600;
            Path=/;
            Secure;
            HttpOnly;
            SameSite=Lax

Every attribute earns its place: an opaque random value so it carries no data and cannot be guessed, Max-Age rather than Expires so no clock agreement is needed, Secure so it never crosses plain HTTP, HttpOnly so XSS cannot exfiltrate it, SameSite=Lax so CSRF form posts fail. Domain is deliberately omitted, so the cookie stays on the exact host that set it rather than spreading to every subdomain.

Three rules on top of that:

Rotate the session identifier on privilege change. Issue a new one at login and at any elevation. Otherwise a session fixation attack works: the attacker sets a known session value on your browser before you log in, and after login that same value is authenticated as you.

Do not put data in the cookie. Store an identifier and keep the data server-side, or sign the cookie if it must be self-contained. An unsigned cookie is entirely under the client's control — role=admin in a cookie is not a joke, it has shipped.

Keep them small and few. They ride on every request in the scope, including every asset.

What the interviewer will push on

"What does HttpOnly actually protect against, and what does it not?" It stops JavaScript reading the cookie, so an XSS cannot exfiltrate the session token. It does not stop the XSS using the session, because the browser still attaches the cookie to requests the injected script makes. Exfiltration versus abuse is the distinction they are listening for.

"Explain CSRF and the defences." The mechanism is that cookies are scoped by destination, so a form on evil.com posting to bank.com carries your session. Then the layers: SameSite=Lax (now the default and it kills the classic attack), a CSRF token the attacker cannot read because of the same-origin policy, Origin header checks, and never using GET for state changes.

"localStorage or an HttpOnly cookie for a JWT?" Name the trade — localStorage is XSS-exposed but CSRF-proof, the cookie is the reverse — then pick the cookie, because XSS is both more common and more damaging, and SameSite handles most of the CSRF exposure.

"What is the difference between a first-party and a third-party cookie?" It depends on which site is in the address bar, not on who set it. The same cookie is first-party in one context and third-party in another. Then explain how one identifier returned across ten thousand embedding sites builds a cross-site profile without any site sharing data.

"You set a cookie and it is not being sent back. Debug it." Walk the scope: Domain and Path must match, Secure blocks it on plain HTTP, SameSite blocks it cross-site, the browser clock affects Expires, and a size limit may have silently dropped it. Then the classic: an old cookie with the same name at a different scope is shadowing the new one.

"Why does a cookie on the apex domain matter?" It is sent to every subdomain — every asset request, and every subdomain you later delegate to a third party. Connecting this back to the DNS choice from Chapter 5.5 is the answer that shows joined-up thinking.

"What actually breaks when third-party cookies are blocked?" Federated sign-on flows, embedded widgets, cross-domain analytics. Then name Partitioned/CHIPS and the Storage Access API as the sanctioned replacements, and note that CNAME cloaking is an evasion browsers are actively closing.

One thing to volunteer: point out that cookies are sent on every request in scope including static assets, so a large cookie is a per-request upload cost on the slower direction of most connections — and that this is why assets are often served from a cookie-free domain. It is a performance consequence of a security mechanism, which is exactly the kind of connection interviewers reward.

Recall

  • Cookies exist because HTTP is stateless; the browser returns them automatically on every matching request, regardless of which page caused it — which is simultaneously why logins work and why CSRF exists.
  • Domain omitted means exactly this host; set, it means this domain and every subdomain, so an apex cookie reaches every asset request and any subdomain you later delegate. The Public Suffix List is what stops anyone setting a cookie for all of .co.uk.
  • Max-Age beats Expires (no clock agreement needed). Deleting requires the same Domain and Path, or you create a second shadowing cookie.
  • Secure = HTTPS only. HttpOnly = invisible to script, which stops exfiltration but not abuse. SameSite=Lax is the browser default and blocks the classic CSRF form post. Partitioned/CHIPS gives an embedded widget a separate jar per top-level site.
  • First-party versus third-party depends on which site is in the address bar, not on who set the cookie. Tracking works because one domain's identifier returns across thousands of embedding contexts.
  • CSRF is a confused deputy — the browser's authority is misused. Defences layer: SameSite, a token the attacker cannot read thanks to the same-origin policy, Origin checks, and never using GET for state changes.
  • Cookies ride on every request in scope including assets, so size is an upload cost; store an opaque identifier, never data, and rotate the identifier at login to defeat session fixation.

Self-test: What does HttpOnly fail to prevent? · Why does deleting a cookie sometimes create a second one instead? · Give the precise definition of a third-party cookie · Which SameSite value is the browser default and what exactly does it block? · Why is an apex-scoped cookie both a security and a performance problem? · Name two things that break when third-party cookies are blocked, and the sanctioned replacement.

Next: 5.7 covers the layer that makes all of this safe to send — TLS, from what the padlock actually asserts to why a certificate is trustworthy at all, and what happens in the milliseconds before the first HTTP byte.