Topic 09

Cookies and State

Identity

A cookie is the browser's way of remembering something on the server's behalf, and every attribute on Set-Cookie is a decision about who can read the value and when the browser sends it back. Stagedoor's session cookie went out without SameSite and without Secure for a year before the security review flagged it. Nothing broke in that year, which is the problem: a missing cookie attribute is not a bug that a test catches, it is a door that stays open until someone walks through it.

This topic is the attributes, the threat each one answers, and one rule that decides what goes inside the cookie at all: the server should remember as little as possible in the browser. A cookie is a key, not a value. The session it points at lives in Redis, where logout is a delete and a stolen laptop is a revocation rather than a 30-day wait.

What a Cookie Is

A name and a value that the browser stores, and sends back in a Cookie header on every later request that matches the cookie's scope. The server sets it once with a Set-Cookie response header and never sees the browser's copy again except as it arrives on requests. The server does not choose when it is sent. The browser does, by the attributes the server wrote when it set the cookie, and a server that forgot an attribute has delegated that decision to the browser's default, which has changed three times in five years.

Stagedoor's web app sets exactly one cookie, sd_session, on login. Its value is a 32-byte random session id, and the session it names is a hash in Redis on redis-01 holding the user id, the login time and the last-seen time. Every request from the browser carries the id; the middleware of Chapter 4 looks the session up and attaches the user to the request. The whole design is that the value means nothing on its own.

Secure, HttpOnly, and Who Can Read It

Secure keeps the cookie off plain HTTP: the browser will not send it on a plain-HTTP request, so a network that downgrades one image fetch cannot collect the session. HttpOnly keeps it away from JavaScript: document.cookie does not include it, so a script injected through a comment field on the event page cannot read it and post it elsewhere. Both are on for the session cookie, both cost nothing, and there is no version of Stagedoor's web app that needs either one off.

Four attributes, four threats
Secureplain HTTP
Never sent on http://. One downgraded fetch on hostile Wi-Fi does not carry the session.
HttpOnlyinjected script
Invisible to document.cookie. A script injected on any page cannot read it and exfiltrate it.
SameSite=Laxcross-site POST
Withheld on a POST from another site. A hostile page cannot submit a refund with the buyer's session.
Max-Agethe stolen laptop
Bounds the lifetime. A 30-day cookie with no server record is a 30-day key nobody can revoke.

SameSite and the Cross-Site Request

A page on another site can contain a form whose action is Stagedoor's refund endpoint and submit it with a script. Before SameSite, the browser attached the buyer's session cookie to that submission because the cookie matched the destination, and the refund went through with her identity. SameSite=Lax sends the cookie on top-level navigations, a click on a link to Stagedoor, but withholds it on cross-site POSTs, embedded fetches and image loads. The hostile form arrives with no session and the middleware answers 401. That one attribute is the defence, and the framework's CSRF token is the second layer for the flows Lax cannot cover.

Strict withholds the cookie even on the navigation: a buyer who follows a link from an email to her order page arrives logged out and has to sign in again. None sends it everywhere, requires Secure, and exists for one reason at Stagedoor, the checkout widget an organizer embeds in her own site, which is a cross-site context by definition and gets its own cookie with its own name. Browsers that see None without Secure drop the cookie silently, and the login works on the developer's localhost and fails in production with no error anywhere.

Domain, Path, Max-Age

Domain and Path are the scope. A cookie without Domain is sent only to the exact host that set it, which is what Stagedoor wants; setting it to the apex would send the session to every subdomain, including one that serves organizer-uploaded files and has no business seeing it. Path=/ is the right scope for a session and has to be written: left out, the browser scopes the cookie to the directory of the path that set it. Max-Age is the lifetime in seconds. A cookie without it dies when the browser session ends, whatever the browser thinks that means; a cookie with Max-Age=2592000 lives 30 days on the disk, survives restarts, and is a 30-day window on a laptop that was left in a taxi.

Stagedoor's session cookie carries Max-Age=2592000, 30 days, and the Redis session behind it carries the same TTL, refreshed on each request. The cookie's lifetime and the server's record are set together on purpose. A cookie that outlives its session is a key to a door that is gone, which is harmless; a session that outlives its cookie is a record nobody can present, which is a leak of memory and nothing else. The dangerous case is the one below: a cookie with no server record at all.

The one Set-Cookie line the web app sends
HTTP/1.1 200 OK
Set-Cookie: sd_session=Vv1c3aG0kM7yQx2bR8hN4w; Path=/; Max-Age=2592000;
            Secure; HttpOnly; SameSite=Lax

The line is the session cookie as the login response sets it. The value is 128 random bits in 22 URL-safe characters, an id with no meaning of its own. The path is the whole site and there is no Domain, so only this host receives it. The lifetime is 30 days, matching the Redis record. And the three flags say, in order: never over plain HTTP, never to JavaScript, and never on a form another site submits. Every attribute is there because a specific attack needs it absent.

Session Cookie vs Bearer Token

The cookie is sent automatically by the browser. That is what makes it convenient, and it is the whole reason cross-site request attacks exist: the browser attaches the credential without asking the page. The bearer token of Chapter 5 is attached deliberately by code, in the Authorization header, on each request the code chooses to make. Nothing attaches it by accident, so there is no cross-site problem, and there is also no HttpOnly, because the code that attaches it must be able to read it.

Stagedoor's web app uses the cookie. The mobile app and the scanner use tokens. The two paths share one session store in Redis, and one middleware resolves either credential to the same user object, so nothing after the boundary knows or cares which one arrived. What the book does not do is mix them on one endpoint: an endpoint that accepts either a cookie or a header is an endpoint whose cross-site defence depends on which one the attacker chose to send.

The Server Should Not Remember

A cookie that carries the whole cart, the buyer's held seats and their prices, is state the server must trust when it comes back and cannot change while it is away. Sign it and the trust problem is solved; the revocation problem is not. A signed cookie that says "seats 14C and 14D at 4,500 cents each" is valid until it expires, whatever happened to the seats, and the server has no way to withdraw it short of rotating the signing key for every buyer at once. It is also 4 kilobytes on every request, including the 40 image and script fetches on the seat-map page.

A cookie that carries only a session id is a pointer the server owns. The cart lives in Redis under that id, changes when the seats change, and disappears when the buyer logs out or the operator revokes it. "The cookie is a key, not a value" is the rule, and it is why session data lives on redis-01 rather than in the browser. The one thing the browser is trusted to remember is which session it is, and even that is trusted only because the server can forget it first.

Cookie vs localStorage for a Token

A cookie with HttpOnly cannot be read by an injected script, and the browser sends it automatically, so it needs SameSite against cross-site requests. Use it for the browser session, where the page's own code never needs to read the credential.

localStorage is readable by any script on the page, and nothing sends it automatically, so it needs no cross-site defence and has no defence at all against script injection: one hostile script and the token is gone. The book puts browser sessions in an HttpOnly cookie and API tokens in the code that attaches them, and puts neither in localStorage.

Common Mistakes
  • SameSite absent — browsers default to Lax now, but a cookie set to None without Secure is dropped silently, and the login works on the developer's machine and fails in production with no error in any log.
  • A session cookie readable by JavaScript — one injected script on any page reads it, posts it elsewhere, and the session belongs to someone else until it expires.
  • Storing data in the cookie — 4 kilobytes on every image and script fetch, and a signed cart that cannot be revoked or corrected until it expires, whatever happened to the seats in it.
  • A 30-day session cookie with no server-side record — logout cannot invalidate it, and a stolen cookie is a 30-day key that no operator can turn off.
  • Domain set to the apex — every subdomain receives the session, including hosts that serve organizer-uploaded content and have no business holding a buyer's credential.
  • One endpoint that accepts both the cookie and a bearer token — the cross-site defence now depends on which credential the attacker's page chose to send, and the cookie path is the one it will pick.
Best Practices
  • Set Secure, HttpOnly and SameSite=Lax on every session cookie by default, and Strict wherever the flow never arrives from a link.
  • Keep the cookie a session id and the session in Redis, with the same TTL on both, so that logout and revocation are one delete.
  • Scope the cookie with Path and leave Domain unset, so only the host that set it ever receives it.
  • Use bearer tokens for non-browser clients and never accept both credential kinds on one endpoint.
  • Give the embedded checkout widget its own SameSite=None; Secure cookie under its own name, so the main session never has to be cross-site.
Comparable toolsDjango sessions, Rails session, express-session, Spring Session: the middleware that sets the attributesRedis the session store behind the idOWASP Session Management Cheat Sheet, where the attributes come fromRFC 6265 and its bis draft, where the attributes and SameSite are defined

Knowledge Check

A hostile page submits a hidden form to POST /refunds. The session cookie carries SameSite=Lax. What happens, and why does a plain link to Stagedoor still work?

  • The refund goes through, because Lax only affects cookies on subresource loads such as images and scripts
  • The form arrives without the cookie and gets a 401, while a click on a link still carries it under Lax
  • Both are blocked, because Lax withholds the cookie on any request that starts from another site
  • The form goes through, but the server rejects it because the Referer header shows the request came from elsewhere

What does HttpOnly defend against that Secure does not?

  • A downgraded request over plain HTTP that carries the cookie across a hostile network
  • A form on another site that submits with the buyer's cookie attached automatically
  • A cookie that outlives its session because no expiry was set when it was issued
  • A script injected through a comment field that reads the cookie and posts it to another server

Why does the book keep the cart in Redis under a session id rather than in a signed cookie?

  • A signed cookie cannot be changed or revoked until it expires, while a server record can be
  • A signed cookie can be forged by anyone who reads it, while a session id in Redis cannot be guessed
  • A signed cookie is slower to verify on each request than a lookup in Redis, which is the deciding cost
  • A signed cookie cannot carry HttpOnly, so any script on the page could read the seats and prices

A session cookie has Max-Age of 30 days and no record on the server. What is the consequence the security review flags?

  • The browser stops sending the cookie after 24 hours, so buyers are logged out daily regardless of the setting
  • Logout cannot invalidate it, and a stolen cookie remains a working key for all of the remaining 30 days
  • The cookie is sent to every subdomain of the site, because a long lifetime widens the cookie's scope
  • Every request carries 4 kilobytes of state, because a 30-day cookie stores the whole cart in the browser

You got correct