Server sessions or tokens for login state
Open in the interactive app: practice, quiz, mock interview →In one minute
- After login, a server must recognise the same user on every later request. It can either keep the state itself and hand out an opaque reference (a session), or hand the state to the client in a signed, self-describing token.
- Sessions make every request pay a lookup but make logout, revocation and permission changes take effect on the next request.
- Tokens remove the lookup from the hot path but cannot be taken back before they expire, so real systems keep them short-lived and pair them with a refresh token, which is server-side state again.
- The choice is less "stateful versus stateless" than "where does the check happen, and how stale may the answer be".
- Transport is a separate decision: an
HttpOnlycookie protects against script theft but needs CSRF defences; anAuthorizationheader avoids CSRF but needs somewhere safe to keep the token.
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
- Checking primary credentials on every request is slow and risky, so login mints a cheaper, narrower credential.
- 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.
- Claims carried by the client go stale, and the server cannot reach into the client to fix them, so tokens must expire quickly.
- 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.
- 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
- Okta, 2023: session tokens leaked through support files. Customers uploaded HAR browser traces to support cases, and those files held live session tokens. An attacker who got into the support system through a service account read files from 134 customers and hijacked sessions at 5 of them. The attacker had access from 28 September to 17 October 2023, and Okta missed suspicious downloads for about 14 days. Okta then shipped admin session binding to network location, so a copied token alone no longer works from elsewhere (Okta).
- CircleCI, 2023: a stolen session cookie skipped 2FA. Malware on an engineer's laptop stole an SSO session cookie that was already 2FA-authenticated, so the attacker simply became that engineer, minted production tokens and pulled encryption keys for customer secrets. Every customer was told to rotate all secrets. Follow-ups included step-up authentication and a much smaller group with production access (CircleCI).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Server-side session + cookie | Instant revocation, tiny cookie, simple mental model | Lookup per request, shared store is a dependency, CSRF defences needed | One web app or a gateway in front of many services |
| Self-contained access token only | No lookup, verifiable by any service or third party | Cannot revoke before exp, large headers, claims go stale | Very short lifetimes, machine-to-machine calls |
| Short access token + refresh token | Lookups only on refresh, revocation bounded by token lifetime | Two credentials to store and rotate, more moving parts | Mobile apps, public APIs, many services |
| Session at edge, internal tokens behind (BFF / phantom token) | Browser never holds a token, services stay stateless | Gateway becomes critical, more infrastructure | Larger web products with many services |
Pitfalls in production
- Tokens in
localStorage. Any XSS can read and exfiltrate them to be replayed from anywhere. Keep browser credentials inHttpOnlycookies, or use a BFF so the browser never holds a bearer token. - No rotation on login. Reusing the pre-login session ID allows session fixation. Issue a new ID on login, on MFA step-up and on role changes, and delete the old one.
- Only idle timeouts. An attacker who keeps a stolen session busy keeps it alive forever. Enforce an absolute lifetime as well, and require re-authentication for sensitive actions. For calibration, OWASP cites idle timeouts of 2 to 5 minutes for high-value apps and 15 to 30 for low-risk ones, and NIST's AAL2 asks for re-authentication after at most 1 hour idle and 24 hours overall (NIST SP 800-63B-4).
- Long-lived access tokens "for convenience". A 24-hour token means a 24-hour revocation window. Keep access tokens to minutes and put the long lifetime on the revocable refresh token.
- Putting authorization into the token and forgetting it. Roles copied into a token at login stay there after a demotion. Keep coarse identity in the token and check fine-grained permissions at the resource, or keep lifetimes short enough that staleness is acceptable.
- Cookie scoping mistakes. A cookie set with
Domain=example.comis sent to every subdomain, including the forgotten marketing site. Use the__Host-prefix, which forcesSecure,Path=/and noDomainattribute (MDN).
Leadership lens
- Ask for the revocation number: how many seconds from "disable this user" to "every request from that user fails"? Security and sales teams will ask; have a measured answer.
- The session store or token service is on the path of every login and often every request. Give it an owner, an SLO and a documented failure mode before it is needed.
- Avoid letting each service invent its own auth. One edge that converts browser sessions to internal tokens is cheaper to audit than 15 teams each parsing cookies.
- Budget for the migration cost: moving from sessions to tokens or back touches every client, so choose with mobile, partner APIs and multi-region plans in view.
Check yourself
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.
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.
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.
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
- RFC 6265: HTTP State Management Mechanism: cookie semantics and browser limits; its draft successor, 6265bis (in final RFC Editor review in 2026), adds
SameSiteand cookie prefixes. - RFC 9068: JWT Profile for OAuth 2.0 Access Tokens: required claims and the
at+jwttype. - NIST SP 800-63B-4 (2025): reauthentication and session timeouts per assurance level.
Systems docs
- Ruby on Rails Security Guide: Sessions: cookie-stored sessions, replay attacks and session fixation.
- Django: How to use sessions: database, cache and signed-cookie backends side by side.
Books
- Designing Distributed Systems (2nd ed.), Brendan Burns, Ch 6 "Replicated Load-Balanced Services": stateless services versus session-tracked services behind a load balancer.
- Building Microservices (2nd ed.), Sam Newman, Ch 11 "Security": human authentication, single sign-on gateways and JSON Web Tokens between services.
Other curricula
- donnemartin/system-design-primer: recommends keeping sessions in a central database or cache so web servers stay stateless; it does not discuss tokens.
- Azure Architecture Center: Valet Key pattern: the same self-contained, time-limited grant idea applied to storage access.
Watch
- Session Vs JWT: The Differences You May Not Know! (ByteByteGo): side-by-side walk through of both designs and where each breaks.
- Session vs Token Authentication in 100 Seconds (Fireship): a fast refresher on the two flows.
Free resources
- OWASP Session Management Cheat Sheet: ID entropy, cookie attributes, timeouts and rotation, all in one place.
- MDN: Set-Cookie: exact semantics of
HttpOnly,SameSite,Domainand the__Host-prefix. - OWASP CSRF Prevention Cheat Sheet: what cookie-based sessions need in addition to
SameSite. - RFC 10017: OAuth 2.0 for Browser-Based Applications (BCP 212, 2026): why the BFF pattern keeps tokens out of the browser; this replaces the long-running IETF draft.
- The Phantom Token Pattern (Curity): opaque tokens outside, JWTs inside, translated at the gateway.
- Stop using JWT for sessions (joepie91): a sharp argument against tokens as browser sessions, worth reading even if you disagree.