JWT and sessions
JWT and Sessions
Understand how JSON Web Tokens and server-side sessions each maintain authenticated state, where their guarantees differ, and how to choose between them without creating revocation or storage problems.
Prerequisites
Recommended Knowledge
- HTTP request and response headers
- Cookies and browser storage mechanisms
- TLS and transport security
- Authentication versus authorization
- OAuth 2.0 and OpenID Connect token types
- Hashing, signatures, and public-key cryptography
- Base64 URL encoding and JSON structure
- Caching and distributed data stores
- Load balancing and stateless service design
The Underlying Problem
HTTP is stateless. Each request arrives independently, carrying no inherent memory of previous requests. Once a user authenticates, the system needs some mechanism to recognize them on every subsequent request without asking for credentials again.
Two dominant approaches exist. The server can remember the session and give the client a reference to it, or the server can hand the client a signed statement that it verifies on each request.
Client Carries Proof Signed Token
Simple Analogy
A session is a coat-check ticket. The number means nothing by itself, but the attendant's register maps it to your coat, and they can cancel it instantly. A JWT is a signed pass printed with your details, readable anywhere, but difficult to recall once handed out.
Server-Side Sessions
In the session model, the server creates a record after successful authentication and returns only an unguessable identifier to the client, usually in a cookie. The authoritative state stays on the server.
CREATE TABLE user_sessions (
session_id VARCHAR(128) PRIMARY KEY,
user_id BIGINT NOT NULL,
created_at TIMESTAMP NOT NULL,
last_seen_at TIMESTAMP NOT NULL,
absolute_expiry TIMESTAMP NOT NULL,
idle_expiry TIMESTAMP NOT NULL,
ip_address VARCHAR(45) NULL,
user_agent_hash VARCHAR(64) NULL,
auth_level VARCHAR(20) NOT NULL,
revoked_at TIMESTAMP NULL
);
CREATE INDEX idx_sessions_user
ON user_sessions (user_id, revoked_at);
Storing the user identifier alongside the session allows a critical operation: terminating every active session for one user in a single statement.
UPDATE user_sessions
SET revoked_at = CURRENT_TIMESTAMP
WHERE user_id = :user_id
AND revoked_at IS NULL;
Session Identifier Requirements
A Safe Session ID
- Generated from a cryptographically secure random source
- Long enough to make guessing infeasible
- Carries no encoded user information
- Regenerated immediately after login
- Regenerated after any privilege change
- Stored hashed if the datastore is a lower trust tier
JSON Web Tokens
A JWT is a self-contained, signed statement. It carries its claims in the token itself, so a recipient holding the correct key can verify it without consulting a central store.
{
"alg": "RS256",
"typ": "JWT",
"kid": "signing-key-2026-09"
}
{
"iss": "https://identity.example.com",
"sub": "user-4821",
"aud": "orders-api",
"exp": 1790000900,
"iat": 1790000000,
"jti": "tok-9c41e7f2",
"scope": "orders.read orders.write",
"auth_level": "mfa"
}
| Part | Contents | Protection Provided |
|---|---|---|
| Header | Algorithm, type, and key identifier | Tells the verifier which key and algorithm apply |
| Payload | Claims about subject, audience, and validity | None by itself, the content is merely encoded |
| Signature | Cryptographic signature over header and payload | Detects any modification of the preceding parts |
Direct Comparison
| Dimension | Server-Side Session | JSON Web Token |
|---|---|---|
| State location | Server datastore | Inside the token itself |
| Validation | Lookup in the session store | Local signature verification |
| Revocation | Immediate | Effective only at expiry unless supplemented |
| Per-request cost | One datastore read | One signature verification |
| Horizontal scaling | Requires a shared session store | No shared state needed for verification |
| Payload visibility | Nothing exposed to the client | All claims readable by the holder |
| Size on the wire | Small identifier | Grows with the number of claims |
| Updating permissions | Takes effect on the next request | Takes effect after the token refreshes |
| Cross-service use | Needs shared access to the store | Any service with the public key can verify |
| Failure coupling | Session store is a dependency | Independent of the issuer at request time |
The Revocation Problem
Because a JWT is verified locally, the verifier has no natural way to learn that the token should no longer be honoured. Until the expiry claim passes, it remains cryptographically valid.
This window matters in concrete situations: a user logs out, an account is suspended, a device is reported stolen, a role is downgraded, or an administrator detects a compromise.
| Mitigation | Mechanism | Cost |
|---|---|---|
| Short lifetimes | Bound the exposure window by expiry | More frequent refresh traffic |
| Denylist by token ID | Check the identifier against revoked entries | Reintroduces a lookup on each request |
| User-level version claim | Compare a token version against the current one | Requires a cached version lookup |
| Introspection | Ask the issuer whether the token is active | Network dependency per request |
| Refresh token rotation | Revoke renewal rather than the access token | Existing access token still valid briefly |
Version-Claim Revocation
async function isTokenStillValid(claims) {
const cacheKey = `session-version:${claims.sub}`;
let currentVersion = await cache.get(cacheKey);
if (currentVersion === null) {
currentVersion = await userRepository
.getSessionVersion(claims.sub);
await cache.set(cacheKey, currentVersion, {
ttlSeconds: 60
});
}
return Number(claims.session_version) === Number(currentVersion);
}
Incrementing the stored version invalidates every outstanding token for that user. The cache TTL determines how quickly the change propagates, which is a deliberate freshness decision.
Validating a JWT Properly
async function verifyAccessToken(token, config) {
const segments = token.split(".");
if (segments.length !== 3) {
throw new Error("Malformed token");
}
const header = decodeSegment(segments[0]);
if (!config.allowedAlgorithms.includes(header.alg)) {
throw new Error("Algorithm not permitted");
}
const key = await keyStore.getKey(header.kid);
if (!key) {
throw new Error("Unknown signing key");
}
const signatureValid = await verifySignature(
`${segments[0]}.${segments[1]}`,
segments[2],
key,
header.alg
);
if (!signatureValid) {
throw new Error("Signature verification failed");
}
const claims = decodeSegment(segments[1]);
const nowSeconds = Math.floor(Date.now() / 1000);
const skew = config.clockSkewSeconds;
if (claims.iss !== config.expectedIssuer) {
throw new Error("Issuer mismatch");
}
const audiences = Array.isArray(claims.aud)
? claims.aud
: [claims.aud];
if (!audiences.includes(config.expectedAudience)) {
throw new Error("Audience mismatch");
}
if (claims.exp + skew < nowSeconds) {
throw new Error("Token expired");
}
if (claims.nbf && claims.nbf - skew > nowSeconds) {
throw new Error("Token not yet valid");
}
if (await denyList.contains(claims.jti)) {
throw new Error("Token revoked");
}
return claims;
}
none algorithm unconditionally, and never derive
the verification strategy from untrusted input.
Cookie Configuration
Whether you carry a session identifier or a token, cookie attributes determine much of the practical security posture.
Set-Cookie: sid=9f2b71c4a83e5d06;
HttpOnly;
Secure;
SameSite=Lax;
Path=/;
Max-Age=1800
| Attribute | Effect | Threat Addressed |
|---|---|---|
| HttpOnly | Blocks script access to the cookie | Token theft via cross-site scripting |
| Secure | Sends only over encrypted connections | Interception over plaintext transport |
| SameSite | Limits sending on cross-site requests | Cross-site request forgery |
| Path | Restricts the cookie to a path prefix | Unnecessary exposure across the site |
| Domain | Controls subdomain sharing | Leakage to less trusted subdomains |
| Max-Age | Bounds the cookie lifetime | Indefinite reuse of stale credentials |
Where Browser Clients Should Store Credentials
| Location | Script Accessible | Assessment |
|---|---|---|
| Local storage | Yes | Any script compromise exposes the credential |
| Session storage | Yes | Same exposure, merely shorter lived |
| Regular cookie | Yes | Readable by scripts, offers little advantage |
| HttpOnly cookie | No | Preferred for browser-facing credentials |
| In-memory variable | Yes | Lost on reload, still script reachable |
Common Justification
- Local storage avoids CSRF entirely
- It is convenient for attaching headers
- Many tutorials demonstrate this approach
Why It Is Weak
- CSRF has well-established defenses
- Script-based theft has no equivalent recovery
- One compromised dependency exposes every token
- HttpOnly cookies remove the theft vector entirely
Cross-Site Request Forgery
Cookies are attached automatically by the browser, which is convenient and also the root of CSRF. A malicious page can trigger a request that carries the victim's cookie.
Layered CSRF Defenses
- Set SameSite on session cookies
- Use anti-forgery tokens on state-changing requests
- Adopt the double-submit cookie pattern where appropriate
- Verify Origin and Referer headers on sensitive endpoints
- Require safe HTTP methods to remain side-effect free
- Require re-authentication for high-risk operations
Expiry Policies
A robust design applies more than one expiry rule, because idle abandonment and long-running compromise are different risks.
Idle Timeout
Ends the session after a period without activity, limiting exposure when a device is left unattended.
Absolute Timeout
Ends the session a fixed duration after creation regardless of activity, bounding the value of a stolen credential.
Renewal Window
Allows extension while activity continues, without permitting indefinite life beyond the absolute limit.
Step-Up Requirement
Demands fresh or stronger authentication before privileged operations, even within a valid session.
The Hybrid Pattern
Most mature systems do not choose one model exclusively. They combine a server-managed session at the edge with short-lived tokens for internal calls.
- The user authenticates and receives an HttpOnly session cookie.
- The edge service holds the authoritative session record.
- Each request resolves the session and confirms it is active.
- The edge mints a short-lived token scoped to the downstream call.
- Internal services verify that token locally without a lookup.
- Logout revokes the session, ending new token issuance at once.
What This Gains
- Immediate revocation at the boundary
- No tokens exposed to browser scripts
- Stateless verification inside the service mesh
- Narrow, per-call token scoping
- Central place for session monitoring
What It Costs
- An additional component to operate
- Session store becomes a critical dependency
- Token minting adds edge latency
- Key distribution must be managed
Choosing Between Them
Signing Key Management
| Aspect | Symmetric Signing | Asymmetric Signing |
|---|---|---|
| Key material | One shared secret | Private signing and public verification keys |
| Verifier needs | The same secret used to sign | Only the public key |
| Forgery risk | Any verifier can also mint tokens | Verifiers cannot mint tokens |
| Best fit | A single trusted service | Multiple independent services |
Key Rotation Practices
- Include a key identifier in every token header
- Publish the current public key set for verifiers
- Accept the previous key during an overlap period
- Cache keys and refresh on unknown identifiers
- Rotate on a schedule and after any suspected exposure
- Store private keys in a managed secret or key service
- Alert on verification failures caused by missing keys
Threats and Mitigations
| Threat | Description | Mitigation |
|---|---|---|
| Session fixation | Attacker plants an identifier before login | Regenerate the identifier after authentication |
| Session hijacking | Credential stolen and replayed | TLS, HttpOnly cookies, anomaly detection |
| Algorithm confusion | Header manipulated to weaken verification | Server-side algorithm allow-list |
| Unsigned token acceptance | The none algorithm is honoured |
Reject unsigned tokens unconditionally |
| Claim tampering | Payload edited to escalate privilege | Verify the signature before reading claims |
| Token replay | Captured token reused elsewhere | Short expiry, audience binding, unique identifiers |
| Stale authorization | Revoked permissions remain in an active token | Short lifetimes or a version claim check |
| Sensitive claim exposure | Private data readable in the payload | Keep sensitive attributes server-side |
| Cross-site scripting | Script reads a browser-stored credential | HttpOnly cookies and content security policy |
| Session store outage | Authentication fails system-wide | Replication, caching, and degradation planning |
Monitoring and Detection
Signals Worth Tracking
- Active session count per user
- Signature verification failures by cause
- Expired token presentation rate
- Audience and issuer mismatch attempts
- Sessions used from unexpected locations
- Concurrent use of one session from distant addresses
- Session store latency and error rate
- Revocation events and their propagation delay
- Denylist size and lookup performance
- Logout completion rate
Common Design Mistakes
Weak Design
- Choosing JWTs purely to avoid a session store
- Issuing tokens valid for days or weeks
- Placing sensitive attributes in the payload
- Storing tokens in local storage
- Trusting claims before signature verification
- Reusing the identifier across login
- Treating logout as a client-side action only
- Applying a single expiry rule
- Ignoring key rotation until an incident
Strong Design
- Chooses based on revocation requirements
- Keeps access credentials short-lived
- Keeps sensitive attributes server-side
- Uses HttpOnly, Secure, SameSite cookies
- Verifies signature, issuer, audience, and expiry
- Regenerates identifiers on privilege change
- Revokes server-side on logout
- Applies idle and absolute timeouts together
- Rotates keys with an overlap window
System Design Interview Discussion
| Question | What Your Answer Should Cover |
|---|---|
| Sessions or JWTs, and why? | Revocation needs, client type, and service topology |
| How does logout work? | Server-side revocation and the residual token window |
| How do you revoke a JWT? | Short expiry, version claim, denylist, or introspection |
| Where is the credential stored? | HttpOnly cookie reasoning versus browser storage |
| How does this scale? | Shared store versus local verification trade-offs |
| What if the session store fails? | Replication, caching, and degradation behavior |
| How are keys managed? | Rotation, key identifiers, and overlap periods |
| How is compromise detected? | Anomaly signals and validation failure monitoring |
Implementation Checklist
Production Checklist
- Generate identifiers from a secure random source
- Regenerate the identifier after every login
- Set HttpOnly, Secure, and SameSite on cookies
- Apply both idle and absolute expiry
- Verify signature before reading any claim
- Allow-list signing algorithms server-side
- Validate issuer, audience, and time claims
- Include a key identifier and support rotation
- Keep access token lifetimes short
- Provide a genuine server-side logout
- Support revoking all sessions for one user
- Exclude sensitive data from token payloads
- Enforce TLS on every authenticated endpoint
- Require step-up authentication for privileged actions
- Monitor validation failures and unusual session use
- Test expired, tampered, and wrong-audience credentials
Knowledge Check
Why is a JWT harder to revoke than a session?
It is verified locally using a signature, so the verifier never consults a central record that could mark it invalid before expiry.
Does signing keep a JWT payload private?
No. Signing provides integrity only. Anyone holding the token can decode and read every claim inside it.
Why regenerate the session identifier at login?
To prevent session fixation, where an attacker supplies an identifier beforehand and inherits the authenticated session.
Why prefer an HttpOnly cookie over local storage?
Local storage is readable by any script on the page, so one scripting vulnerability or compromised dependency exposes the credential.
What does the hybrid pattern achieve?
Immediate revocation at the browser boundary combined with stateless, lookup-free verification across internal services.
Summary
Sessions and JWTs both maintain authenticated state across stateless HTTP requests, but they place that state in different locations. A session keeps authority on the server and hands out a meaningless reference. A JWT hands the client a signed statement that any holder of the verification key can validate.
The decisive difference is revocation. Sessions can be terminated instantly, while JWTs remain valid until they expire unless a lookup mechanism is reintroduced, which partially negates their stateless advantage.
Correct JWT handling requires verifying the signature before trusting any claim, enforcing a server-side algorithm allow-list, and validating issuer, audience, and time claims. Correct session handling requires unguessable identifiers, regeneration at login, layered expiry, and genuine server-side logout.
In browsers, credentials belong in HttpOnly, Secure, SameSite cookies rather than local storage. Most large systems adopt a hybrid: a revocable session at the edge, and short-lived tokens verified locally by internal services.
Key Takeaway
Choose based on revocation, not convenience. Sessions give you immediate control at the cost of a shared store. JWTs give you stateless verification at the cost of a delay between deciding to revoke and that decision taking effect. Keep lifetimes short, keep sensitive data out of payloads, and keep credentials out of reach of browser scripts.