Engineering Atlas › System Design Fundamentals

Server sessions or tokens for login state

15 min read · last reviewed 2026-10-17

Open in the interactive app: practice, quiz, mock interview →

In one minute

How it works

The problem both solve

HTTP is stateless. Checking a password or a passkey on every request would be slow and would spread credentials everywhere, so login is done once and its result is turned into a credential for this session: something cheaper to check, scoped to one device, and expiring on its own.

There are two ways to make that credential. Either it is a random name for a record the server keeps, or it is the record itself, signed so the server can trust it without remembering it. Everything else in this lesson follows from that one difference.

Server-side sessions

At login the server generates 128 or more random bits, stores a record such as {user_id: 42, created: 09:00, last_seen: 09:00, mfa: true, csrf: "..."} under that ID in a store, and sends the ID back, usually in a cookie: Set-Cookie: __Host-sid=Yt3k...; Path=/; Secure; HttpOnly; SameSite=Lax. On each request the server reads the cookie, looks up the record, checks the idle and absolute timeouts, and loads the user.

The ID means nothing on its own. Logout deletes the record. "Sign out of all devices" deletes every record for that user. Demoting an admin takes effect at once, because permissions are read from the record or from the database on every request.

Self-contained tokens

At login the server builds a set of claims, {sub: 42, roles: ["editor"], exp: 09:15}, signs it, and returns the result, most often as a JWT. On each request any server that holds the verification key checks the signature and the expiry, and trusts the claims without asking anyone.

That removes the shared store from the request path, which matters when there are many services, many regions, or third parties verifying tokens. The cost is that the token is a cached copy of the user's authority. If the user is banned at 09:01, the token still says editor until 09:15.

The hybrid everyone ends up with

Because of that cost, token systems issue two credentials. A short-lived access token (5 to 15 minutes) is checked locally on every request. A long-lived refresh token is checked by the auth server, which keeps a record of it, every time a new access token is minted. Revocation means deleting the refresh token record and waiting at most one access-token lifetime. In other words: a session store, consulted every 15 minutes instead of every request.

Large sites often go further. The browser holds an ordinary session cookie, an edge gateway looks it up once, and the gateway attaches a short-lived internal token for the services behind it. The browser never sees a token, and the services never touch the session store. Two named patterns cover this. In the backend for frontend (BFF), a server-side component is the OAuth client, keeps tokens in a cookie-based session and proxies API calls. The IETF's browser-apps best practice, published in August 2026 as RFC 10017, lists it first among its patterns in decreasing order of security and recommends it for business and sensitive applications (RFC 10017). In Curity's phantom token pattern, the client holds an opaque token and the gateway swaps it for a JWT through introspection, caching the result until it expires (Curity).

The problem shape: trusting a cached answer

This is the cache invalidation problem in an auth costume. A token is a cached answer to "who is this and what may they do?", and its exp is a TTL. TLS certificates have the identical trade-off: revocation lists and OCSP are the "look it up" design, and short-lived certificates are the "keep the cache short" design. The industry is choosing the second. Let's Encrypt turned off its OCSP responders in August 2025, partly because every OCSP query tells the CA which site a user is visiting (Let's Encrypt), and the CA/Browser Forum voted in April 2025 to cut the maximum certificate lifetime from 398 days to 47 by March 2029 (CA/B Forum SC-081v3). DNS TTLs are the same decision again. Whenever you see a signed, self-contained artifact, ask what happens between the moment the truth changes and the moment the artifact expires.

Down the stack

Layer 1The contract

A session system promises that a request is accepted only if a matching record exists, now - last_seen < idle_timeout and now - created < absolute_timeout; logout and revocation take effect on the very next request. A token system promises that a request is accepted if the signature verifies with a trusted key and now < exp (within a skew allowance); revocation takes effect after at most the token lifetime, unless a denylist is also consulted. Both promise that a credential issued to one user cannot be forged by another: for sessions because the ID space is too large to guess (OWASP asks for at least 64 bits of entropy, and 128 or more from a CSPRNG when you generate IDs yourself (OWASP Session Management)), for tokens because forging needs the signing key.

Layer 2The mechanism

Django stores sessions in a django_session table with session_key, session_data and expire_date, or in a cache backend. A typical Redis design stores sess:<id> as a hash and calls EXPIRE on each touch to implement sliding idle expiry, plus a set user_sessions:<user_id> so "log out everywhere" is one SMEMBERS and a multi-key DEL. On login and on any privilege change the server rotates the ID: it copies the record to a new ID and deletes the old one, which defeats session fixation, where an attacker plants a known ID in a victim's browser before login.

Rails' default CookieStore shows the boundary is blurry: it puts the whole session, encrypted and authenticated, inside the cookie. That is a self-contained token delivered as a cookie, with the same revocation problem. The Rails security guide spells out the replay version: a user who saves an old cookie can restore an old credit balance, and tracking nonces in a table "would defeat the entire purpose" of the cookie store, so such state belongs on the server (Rails guide).

On the token side, RFC 6750 defines Authorization: Bearer <token>, and RFC 9068 profiles JWT access tokens with required claims iss, exp, aud, sub, client_id, iat and jti (RFC 9068 §2.2). Revocation before expiry is done with a denylist of jti values, kept only until each token's exp, which keeps it small.

Layer 3The machine

A session ID is 32 bytes read from the kernel CSPRNG with getrandom(2), about 43 characters once base64url-encoded. A same-zone Redis HGETALL costs one network round trip, roughly 0.2 to 0.5 ms, and a single Redis core serves on the order of 100,000 such reads per second. A JWT costs no network, but verifying an ES256 signature takes roughly 50 to 100 us of CPU, and an HMAC-SHA256 check about 1 to 2 us. Bytes matter too: a session cookie adds about 60 bytes per request, while a JWT carrying roles and a few claims is often 800 to 1,500 bytes, sent on every request, and browsers cap a single cookie at about 4 KB, the minimum the cookie RFC asks them to support (RFC 6265 §6.1). HTTP/2's HPACK can index a repeated header so later requests on the same connection send only a small reference, but every new connection pays the full size again.

Why it's built this way

  1. Checking primary credentials on every request is slow and risky, so login mints a cheaper, narrower credential.
  2. A random ID needs a lookup, and lookups across services and regions cost latency and couple everything to one store, so tokens carry their own claims.
  3. Claims carried by the client go stale, and the server cannot reach into the client to fix them, so tokens must expire quickly.
  4. Short expiry means frequent re-issue without re-login, so a refresh token is introduced, and it needs a server-side record to be revocable.
  5. The result is the same state in fewer places on fewer requests: the lookup moves from every request to every refresh.

Diagram

sequenceDiagram
  participant B as Browser
  participant A as App server
  participant S as Session store
  participant T as Token service
  B->>A: POST /login
  A->>S: create sess:Yt3k (user 42)
  A-->>B: Set-Cookie __Host-sid=Yt3k
  B->>A: GET /orders + cookie
  A->>S: HGETALL sess:Yt3k
  S-->>A: user 42, last_seen
  A-->>B: 200
  B->>T: POST /token (refresh)
  T-->>B: access JWT (exp 15 min)
  B->>A: GET /orders + Bearer JWT
  A->>A: verify signature, exp
  A-->>B: 200

Worked example

A B2B dashboard has 400,000 daily users, peaks at 30,000 requests per second, and keeps about 1.2 million live sessions (users with several devices, 30-day "remember me").

Sessions. Each record is about 300 bytes plus Redis overhead, say 500 bytes, so 1.2 million sessions use about 600 MB: one small primary with a replica. 30,000 lookups per second is well within one Redis instance, and adds about 0.3 ms per request. The risk is availability: if Redis is down, nobody is logged in. Run it replicated and decide in advance whether the app fails closed (everyone gets a 401) or serves read-only pages.

Tokens. With 15-minute access tokens and 400,000 users active for an average of 3 hours, refreshes are 400,000 x 12 = 4.8 million per day, about 56 per second on average and a few hundred at peak. The auth service and its refresh-token table carry that load; the 30,000 requests per second touch only CPU. The cost: when a customer's admin removes an employee, the employee keeps access for up to 15 minutes, and a 1.2 KB token adds about 36 MB/s of upstream header traffic at peak, compared with under 2 MB/s for a 60-byte cookie.

The team picks sessions at the edge and internal tokens behind it. The gateway does the 30,000 lookups per second, mints a 60-second internal token per request, and 14 backend services verify it locally. Revocation is immediate for the user, and no service shares the session store.

In the wild

Trade-offs

OptionStrengthsWeaknessesChoose when
Server-side session + cookieInstant revocation, tiny cookie, simple mental modelLookup per request, shared store is a dependency, CSRF defences neededOne web app or a gateway in front of many services
Self-contained access token onlyNo lookup, verifiable by any service or third partyCannot revoke before exp, large headers, claims go staleVery short lifetimes, machine-to-machine calls
Short access token + refresh tokenLookups only on refresh, revocation bounded by token lifetimeTwo credentials to store and rotate, more moving partsMobile apps, public APIs, many services
Session at edge, internal tokens behind (BFF / phantom token)Browser never holds a token, services stay statelessGateway becomes critical, more infrastructureLarger web products with many services

Pitfalls in production

Leadership lens

Check yourself

  1. Why does a refresh-token design not make the system truly stateless?

    Show answer

    The refresh token must be revocable, so the auth server keeps a record of it and checks that record on every refresh. State still exists; it is consulted every few minutes instead of every request.

  2. A user changes their password. With pure JWT access tokens of 30 minutes, what happens to their other logged-in devices, and how would you fix it?

    Show answer

    They keep working for up to 30 minutes, and longer if refresh tokens are not revoked. Revoke all refresh tokens for the user, and either shorten access tokens or have services check a per-user "tokens issued before T are invalid" timestamp.

  3. Why do cookie-based sessions need CSRF protection while bearer tokens in a header do not?

    Show answer

    Browsers attach cookies to cross-site requests automatically, so a malicious page can trigger authenticated requests. A header must be set by script on the page itself, which another origin cannot do.

  4. What attack does rotating the session ID at login prevent?

    Show answer

    Session fixation: an attacker who planted a known ID in the victim's browser before login would otherwise share the victim's authenticated session.

Sources and further study

Primary

Systems docs

Books

Other curricula

Watch

Free resources

Open in the interactive app: practice, quiz, mock interview →