Cookies and Session Management
Stateful sessions over a stateless protocol, Set-Cookie / Cookie headers, SameSite, Secure, HttpOnly, and the alternatives.
What it is#
A cookie is a small piece of state — a name and a value — that an HTTP server tells a browser to remember and send back on every subsequent request to the same site. The mechanism is two headers: the server sends Set-Cookie: name=value; ...attributes in a response; the browser stores it and on every future request to that origin includes Cookie: name=value; name2=value2; .... This turns HTTP, a stateless protocol, into something that can carry sessions, preferences, shopping carts, and login state across requests.
Cookies were invented by Netscape in 1994 and standardised first by RFC 2109 (1997), tightened by RFC 6265 (2011), and continually hardened since. Their power and their problems both come from the fact that they’re sent automatically by the browser, including on cross-site requests — which is what made tracking and CSRF possible in the first place.
When to use it#
Reach for cookies when:
- Persisting session identity across HTTP requests on the same site. Login state, cart contents, language preference, A/B-test bucket.
- The state must follow every request to the origin automatically — including HTML page loads. JavaScript-only storage (localStorage, sessionStorage) won’t be sent with navigations.
- You want server-side enforcement of session expiry, revocation, or rotation. Server-controlled cookies (especially HttpOnly) are harder to exfiltrate than tokens kept in JS.
Reach for something else when:
- Cross-origin API calls dominate. Use
Authorization: Bearer <token>instead — explicit, no SameSite footgun, easier to scope. - The data is purely client-side preference.
localStorageorIndexedDB— keeps cookies clean and avoids bloating every request. - Mobile apps. Token-based auth (JWT, OAuth bearer) is the norm; cookies require a cookie jar in the native HTTP client.
- Sensitive secrets. Never store secrets in cookies the JS can read. Either keep server-side (with the cookie as just an opaque session ID) or use HttpOnly.
How it works#
The two headers#
Server -> Browser (response): Set-Cookie: sid=abc123; Path=/; Domain=example.com; Secure; HttpOnly; SameSite=Lax; Max-Age=86400
Browser -> Server (every subsequent request to example.com): Cookie: sid=abc123That’s it. The browser stores the cookie keyed by (domain, path) and replays it on requests that match.
The attributes#
Each attribute controls when the cookie is sent or who can read it:
Domain=example.com— which hosts get the cookie. Default: the origin that set it (host-only). SettingDomain=example.commakes it apply to*.example.com(subdomains too).Path=/api— only sent on requests whose URL path starts with/api. Rarely useful in modern apps.Expires=<date>/Max-Age=<seconds>— when to delete. Without either, the cookie is a session cookie — deleted when the browser closes (in theory; restored on reopen by most browsers).Secure— only sent over HTTPS. Always set this in production.HttpOnly— JavaScript cannot read it viadocument.cookie. Defends against XSS exfiltration.SameSite=Strict | Lax | None— when to send on cross-site requests. Strict never sends cross-site. Lax (the modern default) sends on top-level GET navigations but not on subresources or POSTs. None sends always (must be paired withSecure).
Session cookies vs persistent cookies#
Session cookie: no Expires, no Max-Age. Browser deletes on close.Persistent cookie: Expires=<date> or Max-Age=N. Survives reboot.Most “remember me” checkboxes flip this bit.
Server-side session storage#
Two common patterns:
Pattern 1: opaque session ID Set-Cookie: sid=abc123 server keeps: { abc123 -> { user_id: 42, expires: ..., ... } } pro: small cookie, server can revoke instantly con: needs a session store (Redis, DB)
Pattern 2: signed payload Set-Cookie: jwt=<header>.<payload>.<signature> server keeps: nothing (just the signing key) pro: stateless, scales horizontally without a session DB con: harder to revoke (need a denylist), cookie is largeMost production systems use Pattern 1 because revocation matters (forced logout, password change, lost device).
A full login flow#
1. POST /login {user, pass} (no cookie yet)2. server validates, creates session3. server responds 200 with: Set-Cookie: sid=abc123; Secure; HttpOnly; SameSite=Lax; Max-Age=36004. browser stores5. GET / on next page load Cookie: sid=abc1236. server looks up abc123, gets user 42, renders authenticated page7. logout: server deletes session, responds with Set-Cookie: sid=; Max-Age=0 (deletes the cookie)Variants#
- Signed cookies — payload + HMAC, server verifies the signature on each request. Used by frameworks like Flask, Rails encrypted cookies. Removes the session-store hop at the cost of revocation complexity.
- Encrypted cookies — payload encrypted with a server-side key, optionally also signed. Used when the cookie carries readable session data the user shouldn’t see.
- First-party vs third-party cookies — first-party cookies are set by the site you’re visiting; third-party are set by domains you didn’t visit directly (an embedded ad, a fingerprinting pixel). Modern browsers (Safari, Firefox) block third-party cookies by default; Chrome is phasing them out.
__Secure-and__Host-prefixes — naming conventions enforced by the browser:__Secure-namecookies must be Secure;__Host-namecookies must be Secure, Path=/, no Domain attribute. Strongest defense against subdomain takeover.- Partitioned cookies (CHIPS) — third-party cookies scoped per top-level site. Solves legitimate cross-site embeds (chat widgets, payment iframes) without enabling cross-site tracking.
- Token-based alternatives — JWT, opaque bearer tokens, OAuth access tokens. Used in APIs and mobile apps. Explicit
Authorizationheader, no auto-send, easier to scope.
Trade-offs#
Other trade-offs:
- Auto-send is a feature and a footgun. Convenience for legitimate sessions, source of CSRF for everyone else.
SameSite=Laxis the modern mitigation. - Cookie size. Each cookie is ≤4 KB; total per-domain
~20-50cookies. Bloating cookies inflates every request — keep them small (opaque IDs, not full payloads). - Cross-subdomain coordination. Setting
Domain=example.comshares the cookie across all subdomains. Convenient for SSO; dangerous if any subdomain is compromised.
Common pitfalls#
- Missing
Secure. Cookie sent in cleartext on any HTTP fallback, MITM trivially. Always set in production. - Missing
HttpOnlyon session cookies. XSS that injectsdocument.cookieexfiltrates the session. SetHttpOnlyunless you genuinely need JS to read the value (you usually don’t for sessions). SameSite=NonewithoutSecure. Browsers reject. Less common bug now that defaults are stricter.- Trusting
Domain=without subdomain hygiene. A vulnerable subdomain (old-marketing.example.com) can read or set cookies forexample.com. Use__Host-prefix to opt out. - CSRF via Lax-default GETs.
SameSite=Laxstill sends on top-level GET navigations. A state-changing GET (e.g.,/account/delete?id=42) is exploitable. Use POST and eitherSameSite=Strictor CSRF tokens. - Session fixation. If the app accepts a session ID from the client without rotating it after login, an attacker can pre-set the victim’s session. Always rotate the session ID on authentication.
- Storing JWTs in
localStorage. Any XSS can read it. Prefer HttpOnly cookies or a sandboxed worker. - Forgetting to expire / rotate. Cookies with
Max-Age=31536000(1 year) on a session ID are a credential-theft jackpot. Short expiry + refresh is the norm.
Why did SameSite=Lax become the default?
Until 2020, the cookie default was effectively SameSite=None — cookies sent on every cross-site request. This made CSRF possible by default (clicking a link or loading an image from a malicious site would carry your session cookie). Chrome made SameSite=Lax the default in February 2020 — cookies are no longer sent on subresource loads or cross-site POSTs, but still sent on top-level GET navigations (so “click this link to your bank” still works as expected). The change broke many cross-site embeds that hadn’t set SameSite=None; Secure explicitly. It was the largest CSRF-mitigation step in the web’s history, and it happened by changing one default.
Related building blocks#