Lesson 3 of 12 · 7 min read
Sessions, Cookies and Tokens
In this lesson
TL;DR: A server-side session uses an opaque identifier to retrieve current state. A bearer credential may be opaque or structured; possession lets a caller attempt the operations that its scope and the service policy permit. Protect browser cookies, enforce expiry on the server, and test revocation at every accepting service. Signed tokens permit local validation, but local validation alone does not establish current authorization.
Before you read
Pick an answer before you read further. The lesson below explains it, and guessing first, right or wrong, helps the explanation stay with you.
1An application stores its JWT access token in localStorage. A cross-site scripting bug is found on one page. What can the attacker do that they could not with an HttpOnly cookie?
The problem after login
HTTP is stateless. Once a user has authenticated, every later request needs to carry proof of who they are. There are two broad designs, and most systems mix them.
Server-side sessions
The server creates a session record (user ID, creation time, expiry, maybe roles) and stores it in memory, a database or a cache. The client gets a long random identifier, usually in a cookie. On each request the server looks the identifier up.
- Theft: whoever steals the identifier can use the session until it ends.
- Revocation: invalidate the authoritative record, then account for replicas, cached positive decisions and in-flight requests. Revocation latency depends on those paths.
- Cost: a lookup per request, and shared session storage for multiple servers.
Two classic bugs: session fixation, where the server keeps the same identifier from before login so an attacker who planted it gains access; fix it by issuing a new identifier at login and privilege changes. And predictable identifiers, fixed by using a cryptographic random generator with at least 128 bits.
Entropy and lifetime are separate requirements
Use a cryptographically secure random generator for opaque session identifiers. Predictable timestamps or counters do not contribute secret entropy merely because they make an identifier long. For this site's examples, choose at least 128 random bits; follow the chosen framework's documented session mechanism rather than designing a new token format.
| Design property | Evidence to request |
|---|---|
| Unpredictable identifier | Approved cryptographic generator and sufficient random bits |
| Rotation at authentication | The pre-login identifier cannot access the authenticated session |
| Idle timeout | The server rejects an otherwise valid session after inactivity |
| Absolute timeout | Continued activity cannot extend the session indefinitely |
| Withdrawal | The authoritative session and dependent acceptance paths stop permitting access |
The OWASP session management guidance distinguishes these controls. Select timeouts for the application's risk and workflow, enforce them server-side, and document the residual window introduced by caches. Browser cookie expiry is not a substitute for server expiry.
Bearer tokens
Bearer describes how a credential is used, not its serialization. An opaque access token may require an authorization-server lookup. A signed JWT exposes its claims to its holder and can be checked locally; an encrypted JWT has different confidentiality properties. OAuth does not require JWT access tokens.
A stolen bearer credential may be usable until expiration or effective withdrawal, subject to audience, scope and other enforced policy. Local signature verification cannot detect a role change or logout by itself. Introspection, current authorization checks, revocation state or short lifetimes can bound that gap, with different availability and consistency costs.
Refresh-token revocation prevents future issuance through that grant once enforced; it does not automatically recall access tokens already issued. Refresh rotation also needs reuse handling and a deliberate policy for concurrent legitimate refreshes. Keep token values out of logs.
Cookies: the attributes that matter
Cookies are sent automatically by the browser, which is both convenient and the source of their risks.
| Attribute | What it does | Stops |
|---|---|---|
HttpOnly | JavaScript cannot read the cookie | Token theft via XSS (not XSS itself) |
Secure | Sent only over HTTPS | Theft on plain-HTTP connections |
SameSite=Lax or Strict | Not sent on most cross-site requests | Most cross-site request forgery |
__Host- name prefix | Requires Secure, path /, no Domain attribute | Subdomains overwriting the cookie |
Short Max-Age with idle timeout | Limits how long a stolen session is useful | Long-lived stolen sessions |
For browser applications, an HttpOnly, Secure, SameSite session cookie is usually safer than a token in JavaScript-accessible storage.
Cookie policy must survive browser differences
Set attributes explicitly. Prefix enforcement and partitioned-cookie support depend on the browser, so verify the supported client set rather than assuming a name alone establishes the policy. The browser cookie reference documents the attributes and their conditions. A __Host- cookie constrains domain and path settings in supporting browsers; it does not stop script already executing in the application from invoking authenticated actions.
Keep the server's session lifetime authoritative. A browser may retain, restore or discard cookies independently of that policy. Logout must invalidate server authority as well as clearing the browser cookie.
Where tokens live in a browser app
For single-page applications, the IETF's best current practice for browser-based OAuth clients, RFC 10017 (August 2026), recommends a backend-for-frontend (BFF): a small server-side component that is the confidential OAuth client, keeps access and refresh tokens on the server, and gives the browser only an HttpOnly, __Host- session cookie. The browser calls the BFF, which calls the APIs. OAuth tokens stay out of page JavaScript in this design. Active malicious script can still invoke allowed BFF operations through the browser session, so the BFF needs CSRF protection, constrained proxy destinations and resource authorization. Where a BFF is not possible, a token-mediating backend that hands short-lived access tokens to the page is the fallback, ideally bound to a non-extractable key with DPoP; storing long-lived tokens in localStorage is the pattern to avoid.
Tokens in localStorage
Injected script runs
Reads access and refresh tokens
readable by any script
Copied tokens may outlive the page
Outcome, unsafe: Account taken over off-site
Residual access depends on expiry, binding and withdrawal.
Backend-for-frontend
Injected script runs
Only an HttpOnly session cookie
tokens stay on the server
Acts in-page while open
Outcome, safe: OAuth tokens stay server-side
In-page actions can still disclose data or make lasting changes.
Tokens in the page
Any script that runs can read and export long-lived tokens.
Constrain use of a copied token
Bearer tokens have a structural weakness: possession is enough. Two standards can require possession of an additional key. A copied token alone then fails when the resource server correctly validates the binding and proof:
- DPoP (RFC 9449): the client proves possession of a private key with a signed header on each request.
- Mutual TLS-bound tokens (RFC 8705): the token is bound to the client's TLS certificate.
These controls do not protect against every use of a compromised client runtime. If an unauthorized process can invoke the key operation, non-exportability alone does not prevent misuse. Never use logs as token storage.
Log file
Token copied here
Attacker
Has the token, not the key
API
Checks the token
Also in the picture
Legitimate client
- Holds private key
Plain bearer token
The attacker copies the token from the log and replays it. The API sees a valid signature and unexpired claims, and accepts it.
Run a session lifecycle acceptance fixture
The following fictional policy permits fifteen minutes of inactivity and eight hours of total session age. Times are integer seconds on an exercise clock. The function consumes a fresh authoritative record; it does not parse cookies, authenticate users or demonstrate a distributed cache. A missing record fails closed for this operation.
from dataclasses import dataclass, replace
@dataclass(frozen=True)
class Session:
created: int
last_seen: int
revoked: bool = False
def decision(session, now):
if session is None or session.revoked:
return "deny: withdrawn"
if not session.created <= session.last_seen <= now:
return "deny: clock or record"
if now - session.created >= 8 * 3600:
return "deny: absolute expiry"
if now - session.last_seen >= 15 * 60:
return "deny: idle expiry"
return "allow"
active = Session(100, 1000)
print(decision(active, 1100)) # => allow
print(decision(replace(active, revoked=True), 1100)) # => deny: withdrawn
print(decision(active, 1900)) # => deny: idle expiry
print(decision(Session(100, 28899), 28900)) # => deny: absolute expiry
print(decision(None, 1100)) # => deny: withdrawn
After an allowed request, update activity according to the documented policy. Batching activity writes changes the effective idle behavior. Define atomicity for concurrent logout and refresh so a stale writer cannot recreate a withdrawn session. At a sensitive state change, decide whether permission must be checked again at commit; a request admitted before logout may otherwise finish afterward.
Now keep a copy of active in a resource cache and mark only the authoritative record revoked. The cached copy still returns allow. That is the acceptance test your distributed design must address. Choose a bounded cache lifetime, an authenticated invalidation mechanism, or an authoritative check for the sensitive operation. State how an unavailable session store affects the operation rather than silently permitting indefinitely stale decisions.
Browser
Session store
Resource service
Browser to Session store
1
1. Invalidate the session
Identify the session or family and record withdrawal.
Misconceptions interviewers listen for
- "HttpOnly stops XSS." It stops script reading the cookie; injected script can still act as the user in the page.
- "SameSite replaces CSRF tokens." It is defence in depth: browser defaults differ, the two-minute POST exception exists, and subdomains are same-site.
- "A JWT in localStorage is stateless and therefore safer." Any script in the page can read and export it.
- "Logging out deletes the cookie." The server must invalidate the session, or a copied cookie keeps working.
- "A long session ID is secure." Only random bits count; fixed or predictable parts do not.
What if · interview follow-ups
Hold your answer as the constraints change
You are reviewing session handling for a banking web app built as a single-page application on app.example.com, with marketing pages on www.example.com and a blog on a third-party platform at blog.example.com. The interviewer changes one detail at a time.
Start here
What if session IDs are 32 hex characters but the first 16 encode the server and a timestamp?
How many characters are actually random?
Only 16 hex characters, 64 bits, are random, which meets OWASP's minimum; if any of those are predictable too, the effective space collapses quickly, to about 4,300 guesses per hit with a million live sessions at 32 bits. Generate the whole ID from a cryptographically secure generator with at least 128 bits and keep server routing information elsewhere.
Carry it forwardMeasure session ID strength in random bits, not characters.
Weak answerJudging the ID by its length.
Constraint changes
What if the blog platform is compromised and serves attacker script on blog.example.com?
Is blog.example.com same-site with app.example.com?
Yes, so SameSite does not stop requests from it, and it can set cookies for example.com, which can overwrite or shadow a session cookie set with a Domain attribute. Use a __Host- session cookie, which subdomains cannot set or overwrite, keep CSRF tokens or Fetch Metadata checks on state-changing requests, and consider moving third-party content to a separate registrable domain.
Carry it forwardSubdomains you do not control are inside your site boundary; use __Host- cookies and do not rely on SameSite alone.
Weak answerAssuming SameSite=Strict isolates the app from its own subdomains.
Constraint changes
What if the single-page app stores access and refresh tokens in localStorage?
What can an XSS bug carry away?
Every token, including a long-lived refresh token that keeps working from the attacker's machine after the bug is fixed. Move to a backend-for-frontend, as RFC 10017 recommends, so tokens stay on the server and the browser holds only an HttpOnly __Host- session cookie; if that is impossible, keep access tokens short-lived and bind them to a non-extractable key with DPoP.
Carry it forwardKeep long-lived credentials out of anything script can read.
Weak answerArguing that localStorage is fine because the app has no XSS today.
Constraint changes
What if a refresh token is stolen from a device backup?
What detects reuse, and what makes a copy useless?
Rotation with reuse detection: each use returns a new refresh token, and if an old one is presented again, revoke the whole family, catching the theft the next time either party uses it. Sender-constraining with DPoP or mTLS is stronger, because the copied token is useless without a private key that was never in the backup.
Carry it forwardRotate to detect theft; bind to keys to prevent use of copies.
Weak answerRelying on a long expiry being 'good enough'.
Constraint changes
What if security needs to revoke a user's access instantly across 40 microservices that verify JWTs locally?
How long can a self-contained token outlive revocation?
Until it expires, so keep access-token lifetimes short (for example five minutes), revoke the refresh token and session centrally, and for high-risk operations add token introspection or a revocation event that services consult. State the residual window explicitly: the remaining lifetime of tokens already issued.
Carry it forwardSelf-contained tokens trade revocation speed for scale; bound the gap with short lifetimes and targeted checks.
Weak answerClaiming JWTs can be revoked instantly without extra machinery.
Now defend it
As the defender, what session signals would you monitor?
Session IDs presented from new IP ranges or devices mid-session, refresh-token reuse events and family revocations, sessions exceeding absolute timeouts, failed session lookups suggesting guessing, cookie-setting attempts from subdomains for session cookie names, and logout events followed by continued use of the same session.
How this shows up in interviews
"Sessions or JWTs?" is a common design question, and "it depends" scores only if you name what it depends on: revocation needs, number of services verifying, browser versus server clients, and the theft model. Strong answers mention cookie attributes by name, the localStorage-plus-XSS problem, refresh token rotation, and sender-constrained tokens for high-risk clients such as agents.
Say it in 30 seconds: "A session is continuing authority. I protect its identifier, rotate it at authentication, enforce idle and absolute expiry on the server, and measure withdrawal at accepting services. JWT is a format, bearer is a usage model, and a valid signature is not evidence that permission is still current."
Check yourself
Say or write your answer first, then reveal. Explaining it from memory, in your own words, is the skill an interviewer is checking.
1Why should a server issue a new session ID when a user logs in, even if the browser already had one?
2A user is removed from a project, but their JWT access token says they are a member and is valid for another 12 hours. What would you change?
3A 16-hex-character session ID has 8 fixed characters. How many random bits remain, and why does it matter?
4What does RFC 10017 recommend for where a single-page app's tokens live?