Field note / Engineering+
Sessions vs JWTs: Where Tokens Live, How They Expire, and Why It Matters
A practical comparison of server-side sessions and JWT authentication, covering storage, cookies, expiry, revocation, refresh-token rotation, browser security, and production tradeoffs.
Part 1 of the Production Backend Systems series separated authentication from authorization. Authentication answers who the caller is; authorization decides what that caller may do.
This article goes one layer deeper: after a user proves their identity at login, how does the application recognize that user on the next request?
Two common answers are server-side sessions and JSON Web Tokens (JWTs). They are often presented as competing technologies, but the useful decision is more practical: where should authentication state live, how quickly must it be revoked, which clients need it, and what can the team operate reliably?
Choosing between them affects logout, password changes, permission updates, horizontal scaling, browser security, mobile clients, and incident response. A JWT can be a good fit for one boundary while a session is safer and simpler for another.
Quick Answer
A session stores the important authentication state on the server. The browser stores only an opaque identifier, usually in a secure cookie.
A JWT stores signed claims in a token that the client sends back. The server can verify the signature and claims without looking up a session record on every request.
Server-side session:
Browser sends opaque session ID
↓
Server looks up session state
↓
Server identifies the user
JWT:
Browser sends signed token
↓
Server verifies signature and claims
↓
Server identifies the user
Use sessions when immediate revocation, simple browser security, and server-side control are the priorities. Use JWTs when independently verifiable tokens are genuinely useful across APIs, services, or clients. Do not choose JWTs only because they avoid a database lookup; every real system still needs a plan for refresh, revocation, key rotation, and changing permissions.
What Happens After Login?
The login request is usually only the first authenticated request. A typical flow looks like this:
User submits credentials
↓
Server verifies password or external identity
↓
Server creates an authenticated representation
↓
Browser or client stores that representation
↓
Client sends it with later requests
↓
Server validates it and builds the request principal
The authenticated representation can be:
- An opaque session ID that points to server-side state
- A signed access token containing claims
- A short-lived access token plus a longer-lived refresh token
- A provider session created through an OAuth or OpenID Connect flow
The representation is not the same thing as authorization. It may identify user 42, but the server still needs to check whether user 42 can update invoice 991 in the current tenant.
Server-Side Sessions
The basic model
With a session, the server creates a random, meaningless identifier after successful login. It stores the actual session state in a session store.
Browser cookie:
sid=4d7f...random-value...
Session store:
4d7f...random-value... → {
userId: 42,
tenantId: "acme",
createdAt: "2026-08-03T10:00:00Z",
expiresAt: "2026-08-03T18:00:00Z"
}
The identifier should be generated with a cryptographically secure random generator. It should not contain the user ID, email address, role, or other predictable data.
On a later request:
GET /invoices/991
Cookie: sid=4d7f...
↓
Find sid in session store
↓
Load user 42 and tenant acme
↓
Run authentication and authorization checks
The browser does not need to understand the session contents. It only needs to send the cookie when the cookie rules allow it.
Session lifecycle
Login
↓
Destroy any pre-login session
↓
Create a new random session ID
↓
Store user and expiry metadata
↓
Set a Secure, HttpOnly cookie
Authenticated request
↓
Read cookie
↓
Look up session
↓
Check expiry and account state
Logout
↓
Delete or revoke the server-side session
↓
Clear the browser cookie
Destroying the pre-login session and creating a new identifier prevents session fixation, where an attacker tries to make a victim authenticate into a session identifier the attacker already knows.
Session storage choices
Sessions can be stored in:
| Store | Strength | Risk or limitation |
|---|---|---|
| Process memory | Simple and fast | Lost on restart and not shared between instances |
| PostgreSQL or MySQL | Durable and familiar | Adds database work to authenticated requests |
| Redis or another key-value store | Fast expiry and shared access | Adds infrastructure and operational cost |
| Durable edge storage | Useful near distributed users | Requires platform-specific consistency and expiry design |
In production, process memory is usually appropriate only for local development or a single-instance application where losing every login on restart is acceptable. Multiple application instances need a shared session store or a deliberate sticky-session design.
Advantages of sessions
- The server can revoke one session immediately.
- Password changes and account suspension can invalidate active sessions.
- Permission changes can be reflected by loading current data.
- The browser receives a small opaque value rather than user claims.
- Logout is easy to explain and implement.
Tradeoffs of sessions
- The application needs shared session storage when it scales horizontally.
- Session expiration and cleanup must be designed.
- Cross-origin, mobile, and service-to-service clients need careful cookie or transport decisions.
- A session lookup is part of the request path unless the session is cached elsewhere.
JSON Web Tokens
A JWT is a signed claims container
A JWT commonly has three Base64URL-encoded parts separated by dots:
header.payload.signature
The header identifies metadata such as the signing algorithm. The payload contains claims. The signature lets the server detect changes to the signed content.
{
"sub": "user_42",
"iss": "https://auth.example.com",
"aud": "api.example.com",
"iat": 1785751200,
"exp": 1785751800
}
A JWT is normally encoded, not encrypted. Anyone who obtains the token can usually decode its payload. Do not put passwords, secrets, private personal data, or sensitive business information in it.
What the server must verify
Checking only the signature is not enough. A resource server should validate the token according to its trust relationship and configuration:
- The signature uses an allowed algorithm and trusted key.
- The issuer (
iss) is the expected authorization server. - The audience (
aud) matches this API. - The token is not expired (
exp). - The token is not used before its valid time (
nbf), when present. - The subject (
sub) identifies a valid principal. - Required scopes or claims have the expected type and meaning.
- The token is allowed for this operation and resource.
type Principal = {
userId: string
tenantId?: string
scopes: string[]
}
async function authenticateRequest(token: string): Promise<Principal> {
const payload = await verifyJwt(token, {
issuer: 'https://auth.example.com',
audience: 'api.example.com',
algorithms: ['RS256'],
})
const scopes =
typeof payload.scope === 'string'
? payload.scope.split(' ').filter(Boolean)
: Array.isArray(payload.scope)
? payload.scope.map(String)
: []
return {
userId: String(payload.sub),
tenantId:
typeof payload.tenant_id === 'string' ? payload.tenant_id : undefined,
scopes,
}
}
The example creates a principal, not an authorization decision. The handler still needs to check the requested resource and tenant boundary.
Access tokens and refresh tokens
Most practical JWT systems separate short-lived access tokens from longer-lived refresh tokens.
Login succeeds
↓
Access token: short lifetime, sent to APIs
Refresh token: longer lifetime, used only at token endpoint
↓
Access token expires
↓
Client presents refresh token
↓
Server validates and rotates refresh token
↓
New access token is issued
An access token is used frequently, so its exposure window should be limited. A refresh token has greater value because it can create new access tokens, so it needs stronger storage, rotation, and revocation controls.
Advantages of JWTs
- A service can verify a token without contacting the issuing service for every request.
- Multiple APIs can accept the same token when issuer and audience rules are explicit.
- Mobile and non-browser clients can use an authorization header without cookie behavior.
- Short-lived claims can carry useful request context such as subject and scope.
Tradeoffs of JWTs
- A signed token remains valid until expiry unless the server checks revocation state.
- Role and permission claims can become stale.
- Large tokens increase request size on every call.
- Key management and algorithm configuration are security-critical.
- Logout becomes a client and server coordination problem.
- A refresh-token system still needs state if it supports rotation and reuse detection.
JWTs are not automatically more secure, faster, or more scalable than sessions. They move some state and verification work; they do not remove the need for lifecycle management.
Where Should the Credential Live?
The storage decision is a browser security decision, not just a convenience decision.
HttpOnly cookies
An HttpOnly cookie cannot be read through normal JavaScript APIs. A typical authentication cookie looks like this:
Set-Cookie: sid=4d7f...; HttpOnly; Secure; SameSite=Lax; Path=/; Max-Age=28800
Important attributes:
| Attribute | Purpose |
|---|---|
HttpOnly | Prevents JavaScript from reading the cookie |
Secure | Sends the cookie only over HTTPS |
SameSite | Restricts some cross-site requests |
Path | Limits which paths receive the cookie |
Max-Age or Expires | Controls the browser-side lifetime |
HttpOnly reduces token theft through direct JavaScript reads, but it does not make the application immune to XSS. Malicious script may still cause the browser to perform actions with an attached cookie. The application still needs output encoding, content security controls, input validation, and safe dependency practices.
Cookies are automatically attached, so cross-site request forgery must be considered. SameSite helps, but it should not be the only CSRF defense for sensitive state-changing operations. Use an appropriate CSRF token or same-origin design when required by the application.
Authorization headers
An API client may send an access token explicitly:
GET /api/invoices/991 HTTP/1.1
Authorization: Bearer eyJ...
This avoids the browser automatically attaching the credential to unrelated requests. It is useful for mobile applications and service clients, but a browser application still needs to protect wherever it stores the token. Putting long-lived tokens in localStorage makes them directly readable by injected JavaScript.
There is no storage location that fixes an XSS vulnerability. The goal is to minimize the impact of a script injection and keep the most powerful credential protected for the shortest practical time.
A common browser pattern
For a server-rendered web application, a secure session cookie is often the simplest choice.
For a browser application calling separate APIs, a common design is:
Short-lived access token in application memory
↓
Refresh token in a Secure, HttpOnly cookie
↓
Same-origin refresh endpoint
↓
New access token returned to the application
This reduces the lifetime of the JavaScript-readable access token. It still requires CSRF protection on the refresh endpoint and careful handling of refresh-token rotation.
Expiration, Logout, and Revocation
Session expiration
A session can have several relevant times:
- Absolute lifetime: the maximum age of the login.
- Idle timeout: expiration after inactivity.
- Cookie lifetime: how long the browser keeps the cookie.
- Server expiry: when the session store refuses the session.
The server must enforce its own expiry. Clearing a browser cookie without deleting the server session is incomplete logout, especially if another copy of the cookie exists.
JWT expiration
An access JWT usually has an exp claim. When it expires, the resource server rejects it. The token cannot be edited to extend its life because changing the payload invalidates the signature.
Short expiration limits the damage from theft, but it does not provide immediate revocation. A token with ten minutes left may remain usable for ten minutes unless the server checks a denylist, token version, session record, or another revocation signal.
Revocation comparison
| Event | Server-side session | JWT access token without state |
|---|---|---|
| User logs out | Delete the session | Client discards token; stolen token may remain valid |
| Password changes | Invalidate sessions | Existing tokens may remain valid until expiry |
| Account suspended | Reject or delete session | Requires short expiry or revocation check |
| Role is removed | Reload current permissions | Token claims may be stale |
| Device is revoked | Delete that device session | Requires a token family or revocation mechanism |
JWT systems often add a jti identifier, a token version, or a server-side refresh-session record. At that point, the system is not purely stateless, and that is fine. The correct goal is reliable security behavior, not preserving a marketing label.
Refresh-Token Rotation and Reuse Detection
A refresh token should not be treated as a permanent password. It should belong to a server-tracked token family or login session.
Refresh token R1 issued
↓
Client presents R1
↓
Server marks R1 as used
↓
Server issues access token + refresh token R2
↓
Client presents R2 next time
If an attacker copies R1 and tries to use it after the legitimate client has already exchanged it, the server can detect reuse. A reasonable response is to revoke the entire refresh-token family and require the user to sign in again.
The server-side record might contain:
tokenFamilyId
tokenHash
userId
deviceId
issuedAt
expiresAt
usedAt
revokedAt
replacedBy
Store a hash of a refresh token rather than the raw token when possible. If the database is exposed, an attacker should not immediately gain working refresh credentials from the stored values.
Rotation must also handle legitimate concurrent requests. Two browser tabs may attempt a refresh at nearly the same time. The implementation needs a small grace strategy or an atomic transaction so normal concurrency is not incorrectly classified as theft.
Sessions, JWTs, and Authorization Changes
The authentication mechanism identifies a principal. It should not become the only source of truth for permissions.
Suppose a user has this claim:
{
"sub": "user_42",
"role": "manager",
"exp": 1785751800
}
If the manager role is removed, an unexpired JWT may still contain the old role. A backend that trusts the claim for a sensitive operation can authorize a user who should no longer have access.
Possible controls include:
- Keep access tokens short-lived.
- Check a user or permission version on sensitive requests.
- Look up current permissions for high-risk operations.
- Revoke the session or refresh-token family after a role change.
- Keep authorization decisions resource-aware and tenant-aware.
For many business applications, a token can identify the user while the authorization layer loads current roles and policies from the database or a policy cache.
Token says: user 42
↓
Load current principal and tenant membership
↓
Evaluate invoice.update against invoice 991
↓
Allow or deny
This is especially important for RBAC, ABAC, and multi-tenant systems where access depends on the resource, tenant, ownership, or current business state.
Which One Should You Choose?
Prefer sessions when
- The main client is a browser application.
- Immediate logout and account suspension matter.
- Permissions change frequently.
- You want the server to control authentication state centrally.
- Your application already has a reliable database or Redis deployment.
- You want a straightforward secure-cookie design.
Prefer JWT access tokens when
- Multiple independently deployed APIs need to verify the same identity.
- Mobile or external clients need explicit bearer-token behavior.
- A token must cross a service boundary with a clear issuer and audience.
- The access token is short-lived and the system has a refresh/revocation plan.
- The team is prepared to operate signing keys and validate claims correctly.
Use a hybrid design when
- Browser users need a convenient secure session boundary.
- Separate APIs need short-lived access tokens.
- Refresh sessions need device-level logout and reuse detection.
The hybrid model is not a compromise for its own sake. It separates concerns: the refresh session gives the server control, while the short-lived access token gives APIs a compact identity assertion.
Decision table
| Requirement | Strong starting point |
|---|---|
| Traditional web dashboard | Secure server-side session |
| Single-page app with same-origin backend | Session cookie or BFF-managed session |
| Mobile app calling an API | Short-lived access token plus protected refresh flow |
| Several internal APIs | Signed access token with strict issuer/audience checks |
| Immediate per-device logout | Server-side session or tracked refresh session |
| Frequently changing permissions | Session or token plus current permission lookup |
| Service-to-service authentication | Dedicated workload identity or short-lived signed token |
There is no universal winner. Start with the simplest mechanism that satisfies the actual boundaries of the application.
Practical Implementation Checklist
Before shipping authentication, check the entire lifecycle rather than only the login form.
Session checklist
- Generate session IDs with a cryptographically secure random source.
- Store session data server-side and share it across application instances.
- Use
HttpOnly,Secure, and an appropriateSameSitepolicy. - Rotate the session ID after login and privilege changes.
- Enforce both idle and absolute expiration where appropriate.
- Delete the server session during logout.
- Support per-device session listing and revocation for important accounts.
- Do not put user IDs or roles directly into the session ID.
JWT checklist
- Allow only explicitly configured signing algorithms.
- Validate signature, issuer, audience, expiration, and required claims.
- Keep access tokens short-lived.
- Do not store secrets or sensitive personal data in the payload.
- Design refresh-token rotation before implementing refresh tokens.
- Detect refresh-token reuse and revoke the token family.
- Decide how password changes, role changes, and account suspension revoke access.
- Keep authorization checks outside token parsing code.
- Rotate signing keys with a safe key-discovery and overlap process.
Browser checklist
- Avoid long-lived bearer tokens in
localStoragefor high-value applications. - Protect cookie-authenticated state changes against CSRF.
- Reduce XSS risk with output encoding and a suitable Content Security Policy.
- Never log cookies, access tokens, refresh tokens, or authorization headers.
- Check CORS and credentials behavior explicitly.
- Use HTTPS everywhere outside controlled local development.
Debugging commands
Inspect a response without printing sensitive values into shared logs:
curl -I https://example.com/login
curl -v https://example.com/api/me
For a JWT, decode claims only in a safe local environment. Decoding does not verify the token:
node -e "const p=process.argv[1].split('.')[1]; console.log(JSON.parse(Buffer.from(p, 'base64url')))" 'header.payload.signature'
Use browser developer tools to inspect:
Network → request → Request Headers / Response Headers
Application → Cookies
Console → CORS, CSP, and authentication errors
Do not paste production tokens into online JWT decoder websites.
The Decision I Would Make for a Business Application
The short explanation:
Sessions store authentication state on the server.
JWTs carry signed claims that the server verifies.
The decision should account for:
- Where the browser or client stores each credential
- How cookies, CSRF, XSS, and CORS affect the design
- How logout and account suspension revoke access
- Why JWT claims can become stale when permissions change
- How refresh-token rotation detects token theft
- How issuer, audience, algorithm, and expiration are validated
- Why a JWT is encoded and signed, not automatically encrypted
- Why distributed systems still need state for refresh and revocation
- Why authentication identifies a principal but authorization checks the resource
The best choice is usually the one the team can secure, operate, revoke, and debug consistently.
FAQ
Are JWTs more secure than sessions?
No. They solve the same broad problem using different state and verification models. Security depends on storage, expiry, revocation, cookie policy, key management, claim validation, and backend authorization.
Should I store JWTs in localStorage?
It is convenient, but JavaScript can read values in localStorage. An XSS vulnerability can therefore expose a bearer token. For many browser applications, a secure HttpOnly cookie or a backend-for-frontend design reduces this exposure.
Does HttpOnly prevent XSS?
No. It prevents JavaScript from directly reading the cookie. Injected JavaScript may still perform actions in the victim's browser, so output encoding, CSP, dependency hygiene, and CSRF protections remain important.
Does SameSite completely prevent CSRF?
No. SameSite reduces many cross-site cookie-sending cases, but browser behavior, navigation types, legacy clients, and application requirements vary. Sensitive cookie-authenticated mutations should use a deliberate CSRF defense.
Can I revoke a JWT immediately?
Not from signature verification alone. You need short expiration, a denylist, a token version, a server-side session check, or another revocation mechanism. The more immediate control you need, the more server-side state you will maintain.
Is a refresh token also a JWT?
It can be, but it does not have to be. An opaque, random, server-tracked refresh token is often easier to revoke and rotate than a long-lived self-contained JWT.
Why not put all user permissions inside the JWT?
Because permissions can change before the token expires, and large claims increase request size. Use claims for stable, limited context and evaluate current authorization policy at the backend, especially for sensitive or tenant-scoped resources.
Do sessions work with multiple servers?
Yes. Store sessions in a shared database or cache, or use another deliberate shared-state design. In-memory sessions on each instance cause users to appear logged out when requests reach a different server.
Should access tokens be long-lived to avoid refresh requests?
Usually not. A longer-lived access token increases the exposure window after theft. Use a short-lived access token and design refresh handling, concurrency, rotation, and revocation properly.
What happens when a user logs out with a JWT?
The client can delete its copy, but a stolen token may still work until it expires unless the server checks revocation state. This is one reason sessions or tracked refresh sessions are useful for user-facing applications.
Glossary
| Term | Simple meaning |
|---|---|
| Session | Server-side authentication state associated with an opaque client identifier |
| Session ID | Random identifier sent by a client to locate a server-side session |
| JWT | Signed token containing claims in a standard compact format |
| Claim | Named piece of information inside a token, such as subject or expiry |
| Access token | Short-lived credential used to call a protected API |
| Refresh token | Longer-lived credential used to obtain new access tokens |
| Bearer token | Credential that grants access to whoever possesses it |
| Revocation | Making a credential invalid before its natural expiry |
| Rotation | Replacing a credential with a new one during use |
| Reuse detection | Detecting an old refresh token being presented after replacement |
| CSRF | Forged state-changing request made using a victim's automatically attached credentials |
| XSS | Injected script executing in a trusted application context |
| Issuer | Authority that created and signed a token |
| Audience | Service or resource for which a token is intended |
| Principal | Authenticated actor represented by a request |
Final Mental Model
User proves identity
↓
Application creates an authentication representation
↓
┌───────────────────────────────┐
│ Server-side session │
│ Random ID → server-side state │
│ Easy central revocation │
└───────────────────────────────┘
OR
┌───────────────────────────────┐
│ JWT access token │
│ Signed claims → local verify │
│ Requires expiry and revocation │
└───────────────────────────────┘
↓
Client sends credential with each request
↓
Backend validates authentication
↓
Backend loads current context when needed
↓
Backend checks action, resource, and tenant permissions
↓
Business logic runs
Sessions and JWTs are not substitutes for authorization. They are ways to carry evidence of authentication between requests. A production system still needs safe storage, expiry, logout, revocation, current permission checks, and clear boundaries between browsers, APIs, and services.
The right question is not "Which one is more modern?" It is: where should authentication state live, how quickly must it be revoked, which clients need it, and what security lifecycle can the team operate correctly?
Series / Production Backend Systems
Continue the sequence.
Part 2 / 3