Cookies, Sessions, and State
HTTP is stateless — the server forgets you the instant a request finishes — yet every logged-in site somehow knows who you are on the next click. The bridge is the cookie: the server sends a Set-Cookie header once, the browser stores it, and the browser attaches it to every subsequent request to that site. The server reads the cookie and reconstructs "this is user 4271." That single round trip is what turns a stateless protocol into a session.
Two decisions hang off that cookie and they shape your whole system. First, what does the cookie point at — a session ID that looks up state in a server-side store, or a self-contained token that carries the state itself? That decides your scaling and revocation model. Second, which attributes do you set on the cookie — Secure, HttpOnly, SameSite — because each one closes a specific attack, and missing one leaves it open.
Set-Cookie on responseCookie on each requestThe Cookie Mechanism
A cookie is a name/value pair the server hands the browser with Set-Cookie, plus attributes that scope and time it. Domain controls which hosts receive it — Domain=example.com sends it to every subdomain, while omitting it restricts the cookie to the exact host that set it. Path=/app limits it to URLs under that path. Expires or Max-Age set its lifetime; without either, it is a session cookie that dies when the browser closes.
On every request to a matching host and path, the browser folds all matching cookies into a single Cookie: request header automatically — the application never asks. That automatic attachment is the cookie's whole value and also its central danger: because the browser sends it on any request to the site, including ones triggered by another site, the cookie is the vehicle for cross-site request forgery unless you constrain it.
# a hardened session cookie — every attribute earns its place Set-Cookie: sid=ab39f1c2; Domain=example.com; Path=/; Max-Age=3600; Secure; HttpOnly; SameSite=Lax # Secure -> only sent over HTTPS, never plaintext HTTP # HttpOnly -> hidden from document.cookie, so XSS cannot read it # SameSite -> not sent on cross-site requests (Lax) -> CSRF defense
Security Attributes
Three attributes defend three different attacks, and each defends only its own. Secure tells the browser to send the cookie only over HTTPS, so it never crosses plaintext HTTP where a network attacker could read it. HttpOnly hides the cookie from JavaScript's document.cookie, so a cross-site scripting payload that runs in the page cannot steal the session ID — the cookie still rides requests, it just becomes invisible to injected script.
SameSite controls whether the cookie rides cross-site requests. SameSite=Strict withholds it on any request originating from another site; SameSite=Lax (the modern browser default) sends it on top-level navigations but not on cross-site sub-requests like a hidden form POST, which blocks most CSRF; SameSite=None sends it everywhere but is only honored together with Secure. These do not overlap — HttpOnly does nothing against CSRF, and SameSite does nothing against a stolen-over-HTTP cookie. You set all three.
Server-Side Sessions versus Self-Contained Tokens
With a server-side session, the cookie holds only an opaque ID; the real state — user, roles, cart — lives in a store the server controls (memory, Redis, a database). The server looks up the ID on each request. The win is revocation: delete the row and the session is dead instantly, everywhere. The cost is that every request hits the store, and if the store is one server's local memory, the user is pinned to that server.
With a self-contained token — a signed JWT is the common form — the cookie carries the state itself: user ID, roles, and an expiry, signed so the server can verify it without a lookup. Any server can validate it with the signing key, no shared store needed, which scales beautifully. The brutal tradeoff is revocation: a signed token is valid until it expires, so you cannot kill a stolen or logged-out token before its exp without bolting on a denylist that reintroduces the very lookup the token was meant to avoid.
Scaling Sessions Across Servers
Server-side sessions force a choice once you run more than one app server. Sticky load balancing (session affinity) pins each client to the server that holds its session, usually by hashing a cookie the balancer sets. It is simple, but it defeats even load distribution and, worse, kills failover — when that server dies, every session it held is gone, and the balancer reroutes users to a server that has never seen them.
The alternative is a shared session store: put the sessions in Redis or a database that every app server reads, and drop the affinity. Now any server handles any request, a dead server costs no sessions, and you scale the app tier freely — at the price of an external store you must keep available, since if Redis goes down, every session goes with it. Most production systems pick the shared store, or sidestep the whole question with stateless tokens.
Server-side session. The cookie is an opaque ID; state lives in a central store. Revocation is instant — delete the record and the session dies everywhere — but every request needs a lookup, and you must run and share that store. Choose it when you need to revoke sessions immediately (banking, admin consoles) and can afford a Redis or database dependency.
Stateless token. The cookie carries signed state, so any server validates it with the key and no store is hit. It scales without shared infrastructure, but a valid token cannot be revoked before it expires without adding a denylist that reintroduces the lookup. Choose it for high-scale APIs where short expiries are acceptable and instant revocation is not a hard requirement.
- Omitting
HttpOnlyon a session cookie. A single XSS bug can then readdocument.cookieand exfiltrate the session ID, handing the attacker a logged-in session no password reset will close until the cookie expires. - Omitting
Secureso the cookie rides plaintext HTTP. A network attacker on the same Wi-Fi captures it in cleartext on the first non-HTTPS request, and HTTPS everywhere else does not help once it leaked. - Leaving
SameSitewrong for the flow. Too strict and legitimate cross-site logins or embedded widgets silently stop receiving the cookie and break; too loose (Nonewithout need) and you reopen the CSRF hole the attribute exists to close. - Using sticky sessions and assuming failover works. When the pinned server dies, every session it held vanishes and users are bounced to a server that has never seen them — a deploy or crash logs everyone out mid-task.
- Issuing long-lived self-contained tokens you cannot revoke. A leaked or post-logout token stays valid until
exp; without a denylist there is no way to kill it, so a 24-hour token is a 24-hour breach window.
- Set
Secure,HttpOnly, and an explicitSameSiteon every session cookie, since each defends a different attack and the browser defaults do not cover all three for your flow. - Default
SameSite=Laxfor session cookies and reserveSameSite=None; Secureonly for cookies that genuinely must cross sites, so CSRF is closed without breaking top-level logins. - Use a shared session store (Redis, a database) instead of sticky affinity once you run more than one app server, so failover and even load distribution actually work.
- Keep self-contained tokens short-lived (minutes, not days) and pair them with refresh tokens, so the revocation gap is small and a leaked access token expires fast.
- Add a token denylist or short rotation when you adopt stateless tokens and need real revocation, accepting the lookup it costs as the price of killing a compromised session before
exp.
Knowledge Check
A session cookie is set with HttpOnly but not SameSite. Which attack is still open?
- Cross-site request forgery, because the cookie still rides forged cross-site requests
- Cross-site scripting reading the cookie value directly, which the HttpOnly attribute somehow fails to block
- Network interception over plaintext HTTP, which only SameSite prevents
- Session fixation, since SameSite is what regenerates the session ID
What is the core tradeoff of a self-contained signed token versus a server-side session ID?
- It scales without a shared store but cannot be revoked before it expires
- It is encrypted end to end, so it is more secure but slower to verify
- It needs a central database lookup on every single request, so it ends up scaling considerably worse
- It can be revoked instantly but forces sticky load balancing
Why does sticky session affinity hurt failover?
- Each client's state lives on one pinned server, so its death loses those sessions
- It exposes the affinity cookie, letting attackers hijack other servers' sessions
- It spreads each session across all servers, so any failure corrupts state
- It inherently depends on a shared external store whose outage then takes every single server down at once
You got correct