Suriyaprakash.
Engineering

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.

Published
Reading time20 min read
Filed underEngineering

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:

StoreStrengthRisk or limitation
Process memorySimple and fastLost on restart and not shared between instances
PostgreSQL or MySQLDurable and familiarAdds database work to authenticated requests
Redis or another key-value storeFast expiry and shared accessAdds infrastructure and operational cost
Durable edge storageUseful near distributed usersRequires 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:

AttributePurpose
HttpOnlyPrevents JavaScript from reading the cookie
SecureSends the cookie only over HTTPS
SameSiteRestricts some cross-site requests
PathLimits which paths receive the cookie
Max-Age or ExpiresControls 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

EventServer-side sessionJWT access token without state
User logs outDelete the sessionClient discards token; stolen token may remain valid
Password changesInvalidate sessionsExisting tokens may remain valid until expiry
Account suspendedReject or delete sessionRequires short expiry or revocation check
Role is removedReload current permissionsToken claims may be stale
Device is revokedDelete that device sessionRequires 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

RequirementStrong starting point
Traditional web dashboardSecure server-side session
Single-page app with same-origin backendSession cookie or BFF-managed session
Mobile app calling an APIShort-lived access token plus protected refresh flow
Several internal APIsSigned access token with strict issuer/audience checks
Immediate per-device logoutServer-side session or tracked refresh session
Frequently changing permissionsSession or token plus current permission lookup
Service-to-service authenticationDedicated 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 appropriate SameSite policy.
  • 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 localStorage for 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

TermSimple meaning
SessionServer-side authentication state associated with an opaque client identifier
Session IDRandom identifier sent by a client to locate a server-side session
JWTSigned token containing claims in a standard compact format
ClaimNamed piece of information inside a token, such as subject or expiry
Access tokenShort-lived credential used to call a protected API
Refresh tokenLonger-lived credential used to obtain new access tokens
Bearer tokenCredential that grants access to whoever possesses it
RevocationMaking a credential invalid before its natural expiry
RotationReplacing a credential with a new one during use
Reuse detectionDetecting an old refresh token being presented after replacement
CSRFForged state-changing request made using a victim's automatically attached credentials
XSSInjected script executing in a trusted application context
IssuerAuthority that created and signed a token
AudienceService or resource for which a token is intended
PrincipalAuthenticated 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

About the author

Suriyaprakash Somu is a full-stack developer from Erode, Tamil Nadu, building production-ready business applications with React, Node.js, Fastify, PostgreSQL and MySQL. He focuses on Access Control, schema-based forms, and reliable backend workflows.