Topic 25

Sessions vs Tokens

Identity

The password is verified once. On the next request, 200 milliseconds later, the service must know it is the same buyer without asking again, and there are two ways to do that. A session: the server writes "session 7f3a belongs to user 812" in a store it controls, and the client carries only the id. A token: the server signs the statement "user 812, until 19:57" and the client carries the statement itself. The first costs a lookup on every request and can be revoked by deleting one row. The second costs nothing to verify and cannot be taken back until it expires.

Stagedoor's first implementation had one mechanism for every client, a JWT that lived until the browser closed, and the security review found it could not be revoked, was readable by any script on the page, and had been copied into the mobile app's crash reports. Marek's rebuild uses both designs on purpose: the browser gets a session in an HttpOnly cookie, the mobile app gets a 15-minute access token paired with a 30-day refresh token that is, on inspection, a session by another name, and the scanner carries its API key in the same header. Both paths end in the same Principal in the request context, and nothing below the auth ring knows which one produced it.

The Session

At login the service draws 128 bits from the operating system's random source, encodes them as 22 URL-safe characters, and that string is the session id. It writes a hash of session:{id} to Redis with the user id, the creation time, the last-seen time and the client that logged in, and sets a TTL of 30 days. The id goes back to the browser in a cookie, Secure, HttpOnly, SameSite=Lax, as Chapter 2 specified. On every request the auth ring reads the cookie, fetches the hash from Redis in 200 microseconds, and builds the principal from the user id inside it. The id itself means nothing: it is a key, not a claim, and 128 bits of randomness means guessing one is not a strategy.

Revocation is a delete. Logout deletes session:{id}. "Sign out everywhere," the button a buyer presses when her laptop is stolen, deletes every id in the set user-sessions:{user_id} that the login path added to. An organizer removing a staff member deletes that person's sessions, and the removed staff member's next request, 200 milliseconds later, is a 401. The stolen cookie is dead the moment the row is, and there is no expiry to wait out. That single property is the reason the browser gets a session, and the rest of the topic is about what the other design gives up to avoid the lookup.

The Bearer Token

A bearer token is a signed statement the client presents in Authorization: Bearer, and whoever bears it is believed. The server verifies the signature and reads the claims, user 812, expires 19:57, with no store involved: verification is a signature check in memory, and a service that has the key can accept a token it never issued, which is what makes tokens right for the places sessions cannot reach. The trade is that the server has no row to delete. A stolen token is valid until exp, and the only way to shorten that window is to make the token short.

So the access token lives 15 minutes. That is short enough that a token lifted from a crash report is probably expired before anyone reads it, and long enough that the app is not re-authenticating on every tap. To get a new one without a password, the app holds a refresh token: 256 random bits, valid 30 days, stored server-side as a hash with the user id and the device, presented once to POST /token to receive a fresh access token and a fresh refresh token, at which point the old refresh token is dead. A refresh token that is presented twice is a stolen one, and the service revokes the whole family. Read that description again: a random id, a server-side row, a delete to revoke. The refresh token is a session. The token design does not remove the lookup, it moves the lookup off the hot path, from every request to once per 15 minutes.

Two credentials, two shapes, one principal on the far side
# the browser: a cookie the server can look up and delete
Cookie: sd_session=Vv1c3aG0kM7yQx2bR8hN4w
  Redis  session:Vv1c3aG0kM7yQx2bR8hN4w  ->  {user_id: 812, created: ..., last_seen: ..., client: web}  TTL 30d

# the mobile app: a signed statement, verified in memory, gone in 15 minutes
Authorization: Bearer eyJhbGciOiJFZERTQSIsImtpZCI6IjIwMjYtMDkifQ.eyJzdWIiOiI4MTIiLCJleHAiOjE3OTA0NTI2MjB9.…
  claims  {sub: "812", exp: 19:57}       # no lookup; a row exists only for the refresh token

# both end here, and the domain never learns which path produced it
Principal(user_id=812, organizer_id=None, roles={"buyer"}, via="session")

The top half is the cookie and the Redis row it points at: the id carries no information, the row carries all of it, and the row has a TTL. The middle is the bearer token, three base64 segments the next topic takes apart, whose claims are the information and whose signature is the proof, with no row at all until the app needs a new one. The bottom is what the auth ring produces in both cases: one Principal with the user, the organizer if any, the roles, and a note of which mechanism established it, which the audit log records and nothing else reads.

Which Client Gets Which

The browser gets the session. The cookie is automatic, so the frontend writes no code to attach it; it is HttpOnly, so an injected script cannot read it; and it is revocable, which for a client that lives on shared laptops and in internet cafés is the property that matters. The one thing the cookie costs is SameSite discipline, which Chapter 2 paid.

The mobile app and the scanner get tokens. They have no cookie jar worth trusting, they attach Authorization explicitly on every call, and they run in places where a lookup on every request is the wrong trade: the scanner reads 2,000 tickets in the 40 minutes before a show, and a Redis round trip on each is 2,000 round trips the signature check does not need. The scanner's credential is an API key rather than a user's token, which Topic 28 covers; the mobile app's is the access-and-refresh pair. In every case the auth ring produces the same Principal, and the domain's get_order(principal, public_id) of Topic 29 is written once.

Which credential each of Stagedoor's clients carries, and why
The browser: shared machines, injected scripts, a cookie jarsession id in an HttpOnly cookie, revocable by delete
The mobile app: explicit headers, a device to bind to15-minute access token plus a 30-day refresh token
The scanner: a machine acting for one organizeran API key per organizer, Topic 28
A verifier that cannot reach Redis: a CDN edge, a partnera JWT signed with an asymmetric key, Topic 26

Revocation Is the Whole Difference

Every other difference between the two designs is a matter of cost. The one that decides is what happens when a credential must stop working before its time. A session is revoked by deleting it. A token is revoked by waiting for exp, or by keeping a denylist of revoked token ids that the verifier checks on every request, which is a session store with the polarity reversed and the same Redis round trip. Any design that needs instant revocation needs a lookup somewhere, and the honest question is only where to put it.

Stagedoor's answer is to put the lookup where the requirement is. The browser's session is looked up on every request, because the buyer on a stolen laptop needs it gone now. The app's access token is not looked up at all for 15 minutes, because the app is on a device the buyer holds, and the refresh token behind it is looked up once per 15 minutes and is the thing "sign out everywhere" deletes. A staff member removed by an organizer mid-shift is the hard case: the removal must take effect before any token expires, and Topic 29 handles it by reading the current role from the store at check time rather than trusting a role claim, which is the session's cheap lookup again, spent on the one fact that changes.

Where the Session Lives

Redis, with a TTL, on redis-01. Sessions are read on every request that carries a cookie, most of the 3,000 a second at on-sale, and a lost session is a re-login rather than a lost order, which is the profile Redis is for. The Postgres alternative puts thousands of point reads a second on pg-primary for data that is never joined and never reported on, and the pool of 20 was sized for checkout. The in-process alternative, a dictionary in the api process, is the design Stagedoor had before it had two instances: api-02 does not know the session api-01 created, and login works exactly as often as the load balancer happens to send both requests to the same host, which Chapter 14 names as the sticky-session lie.

Sliding vs Fixed Expiry

A session that extends its TTL on every request lives as long as the buyer is active, which is what a buyer expects and what an attacker with a stolen cookie enjoys equally. A fixed absolute limit caps it regardless of activity. Stagedoor does both: the auth ring resets the TTL to 30 days on each request, and the session row carries its creation time, and a session older than 90 days is refused and deleted even if it was used a minute ago. The buyer re-authenticates once a quarter at most. The stolen cookie has a ceiling. And the creation time in the row is what support reads when a buyer asks why she was signed out, which turns "the session expired" from a guess into a timestamp.

Session vs JWT for a Browser

A session costs one Redis read of 200 microseconds per request and can be ended the instant a row is deleted. The cookie is automatic and HttpOnly. For a browser, where the credential lives on hardware the service does not control and revocation is the property that matters, the session wins.

A JWT costs no read and cannot be ended before exp without a denylist, which is a session store with extra steps. It earns its place where the verifier cannot reach the session store at all: a CDN edge checking a signed URL, a partner service, or the identity provider's id token of Topic 27. Using it for a browser session gives up revocation to save a round trip the browser never notices.

Common Mistakes
  • Long-lived JWTs with no refresh token — a token stolen from a crash report is valid for its whole 30 days, and the service has no row to delete and no way to stop it.
  • Sessions in process memory — api-02 does not know the session api-01 created, and login works on exactly the fraction of requests the load balancer sends to the same host.
  • A session id from a weak generator — a timestamp, a counter or a language's default random module is a predictable id, and a predictable id is a login without a password.
  • Not rotating the session id at login — an attacker who planted an id in the victim's browser before login owns the session after it, because the id never changed.
  • The refresh token in localStorage — any script injected into the page reads it, and it is the 30-day credential, so the 15-minute access token protected nothing.
  • Sliding expiry with no absolute cap — a stolen cookie used once a week lives forever, and support cannot say when the session began.
Best Practices
  • Give browsers a session in a Secure, HttpOnly, SameSite=Lax cookie, and give apps and machines a 15-minute access token paired with a revocable, server-side refresh token.
  • Draw every session id from the operating system's random source, 128 bits, and issue a new id at login and at any privilege change.
  • Store sessions in Redis with a 30-day sliding TTL and a 90-day absolute cap, keyed by id and indexed by user so "sign out everywhere" is one delete per row.
  • Rotate the refresh token on every use and revoke the whole family when a used one is presented again.
  • Produce one Principal type in the context whatever the credential was, and let nothing below the auth ring branch on how it was established.
Comparable toolsRedis the session store, with TTL as the expiryDjango sessions, Rails sessions, express-session and Spring Session the framework session layers, all with a pluggable storeOAuth 2.0 refresh tokens the standardized form of the access-and-refresh pair, Topic 27Auth0, Cognito and Firebase Auth managed providers that issue all of it

Knowledge Check

A buyer's laptop is stolen with her browser logged in to Stagedoor. What can the session design do that a pure JWT design cannot?

  • Refuse the cookie from an unknown device, because the session is bound to the laptop's fingerprint
  • End the credential immediately by deleting its row, instead of waiting for an expiry
  • Hide the session id from the thief, because it is encrypted rather than signed
  • Detect that the cookie is being used from a new location and require the password again

Why does the mobile app get a 15-minute access token and a 30-day refresh token instead of one 30-day token?

  • Because a 30-day token is slower to verify, since the signature covers a longer validity window
  • Because the refresh token comes from the identity provider and the access token from Stagedoor
  • Because the access token is looked up in Redis on every call and a 30-day one would fill the store
  • Because a stolen access token dies in minutes, while the long-lived credential has a row that can be revoked

Stagedoor stores sessions in a Python dictionary inside the api process. What does a buyer experience with two instances behind the load balancer?

  • Login works on some requests and fails on others, depending on which instance answers
  • Login works everywhere, because the load balancer replicates the dictionary to the second instance
  • Login fails on every request, because in-process sessions cannot be read after the response ends
  • Login works until the dictionary runs out of memory, after which both instances start refusing

Why does the auth ring issue a fresh session id at the moment of login rather than keeping the one the browser already had?

  • So that the sliding TTL restarts from zero on a clean key rather than inheriting the old one
  • Because a session created before login has no user id and cannot be updated in place
  • So an id an attacker planted before login does not become an authenticated session after it
  • Because 128 bits of randomness is only guaranteed for ids generated after authentication

You got correct