Published

JWT vs Session Tokens: How to Choose

Both JWTs and server-side session tokens solve the same fundamental problem: how to maintain authentication state across multiple HTTP requests without requiring the user to log in on every call. They solve it in opposite ways, and choosing the wrong approach for your architecture adds unnecessary complexity or security risk. This guide explains the tradeoffs and gives you a clear decision framework.

The Problem Both Solve

HTTP is stateless. Each request is independent — the server has no memory of previous requests from the same client. Authentication requires some mechanism to let the server recognise a previously authenticated user without requiring their credentials on every request.

The two dominant approaches are:

  • ·Server-side sessions: The server stores the authentication state. On login, it creates a session record in a database or cache, assigns it a random ID, and sends that ID to the client (usually as a cookie). On each subsequent request, the client sends the session ID and the server looks up the session to find the user's identity.
  • ·JWTs: The server issues a self-contained token that encodes the authentication state directly. The token is signed (and optionally encrypted). On each request, the client sends the token and the server verifies it locally — no database lookup required on the hot path.

Side-by-Side Comparison

PropertyJWTSession token
State storageClient (self-contained)Server (database / Redis)
VerificationCryptographic (no DB lookup)Database lookup per request
Horizontal scalingEasy (no shared state)Requires shared session store
RevocationHard (must expire or blocklist)Easy (delete session record)
Token sizeLarger (200–1000 bytes)Small (16–32 byte random ID)
Multi-service / SSONatural fitRequires centralised session store
Security on compromiseWindow = remaining token lifetimeRevoke immediately
Cross-domain authWorks nativelyRequires CORS + SameSite config
Implementation complexityModerate (key management)Simple (database record)

How Server-Side Sessions Work

When a user logs in, the server creates a session record and stores it in a persistent backend (typically a relational database or an in-memory store like Redis):

// On login:
session_id = generateSecureRandomId(32)  // e.g., 32 bytes of random hex

db.sessions.create({
  id: session_id,
  userId: user.id,
  createdAt: now(),
  expiresAt: now() + 7 days,
  ipAddress: request.ip,          // optional: tie session to IP
})

response.setCookie('session_id', session_id, {
  httpOnly: true,
  secure: true,
  sameSite: 'Lax',
  maxAge: 7 * 24 * 60 * 60,       // seconds
})

// On each subsequent request:
session_id = request.cookies.session_id
session = db.sessions.findById(session_id)

if (!session || session.expiresAt < now()) {
  return 401 Unauthorized
}

// Use session.userId for authorization
const user = db.users.findById(session.userId)

The session ID is an opaque random string — it carries no information itself. Its only purpose is to reference a server-side record. This means the server fully controls the session state: it can expire it, invalidate it, or attach additional data without issuing a new token.

Session scaling challenges

In a single-server application, session state is local and the lookup is fast. In a multi-server deployment behind a load balancer, each server needs access to the same session store. This typically means a shared Redis cluster or a database with read replicas.

Every authenticated API call incurs a round-trip to the session store. At high request rates (hundreds of thousands of requests per second), this becomes a bottleneck. Redis mitigates the latency (sub-millisecond reads), but the session store itself becomes a single point of failure that must be made highly available.

How JWTs Work as Session Replacements

JWTs move state storage from the server to the client. All the information the server needs to authenticate a request is encoded directly in the token:

// On login:
const accessToken = jwt.sign(
  {
    sub: user.id,
    iss: 'https://auth.example.com/',
    aud: 'https://api.example.com',
    iat: Math.floor(Date.now() / 1000),
    exp: Math.floor(Date.now() / 1000) + 15 * 60,  // 15 minutes
    roles: user.roles,
  },
  privateKey,
  { algorithm: 'RS256' }
)

// On each subsequent request:
const payload = jwt.verify(token, publicKey, {
  algorithms: ['RS256'],
  issuer: 'https://auth.example.com/',
  audience: 'https://api.example.com',
})

// Use payload.sub for authorization — NO database lookup needed

The API server needs only the public key (for RS256/ES256) to verify any token. It does not need a connection to the auth server or a session store on the request path. This makes each API server instance fully independent — add or remove instances without any coordination.

The Revocation Problem

The most significant operational difference between the two approaches is how they handle revocation — the need to invalidate an active token before it expires.

Sessions: trivial revocation

Revoking a session is a single database delete:

// Logout — immediate revocation
db.sessions.delete({ id: session_id })
// Next request with this session_id → not found → 401

Revoking all sessions for a user (e.g., after a password reset or account compromise) is equally simple:

db.sessions.deleteAll({ userId: user.id })

JWTs: revocation requires statefulness

A valid JWT remains valid until it expires, regardless of what happens on the server. You cannot "delete" a JWT once it's been issued. Three strategies exist:

  • ·Short expiry + refresh tokens: Issue access tokens with 5–15 minute lifetimes. On logout, revoke the refresh token. The access token remains technically valid until expiry, but the attacker's window is limited to a few minutes. This is the most common production approach.
  • ·Blocklist (jti-based): Maintain a server-side store of revoked token JTI values. Check every incoming token against the blocklist. This provides immediate revocation but reintroduces the database lookup on every request — partially defeating the stateless advantage of JWTs.
  • ·Version claims: Store a token_version in the user's database record. Include it in the JWT. On each request, verify the claim matches the current version. Incrementing the version invalidates all existing tokens. This is a lightweight form of per-user revocation that requires one database read per request — similar to session lookup overhead.
None of these strategies is as simple as deleting a session record. If your application requires immediate revocation (financial transactions, admin panels, healthcare systems), account for this complexity in your design — or use sessions instead.

Token Storage and Security

Both session IDs and JWTs must be protected from theft. The attack surface differs:

Session ID storage

Session IDs are almost always stored in HttpOnly cookies. The browser handles the transmission automatically and JavaScript cannot access the value, making the session immune to XSS token theft. CSRF (cross-site request forgery) is the primary threat when using cookies, mitigated by SameSite attributes and CSRF tokens for state-changing operations.

JWT storage

JWTs are commonly stored in one of three places, with different security implications:

  • ·HttpOnly cookie: Same XSS immunity as sessions. Recommended when possible. Requires CSRF protection for mutations.
  • ·localStorage: Accessible to JavaScript, vulnerable to XSS. Any injected script can steal the token. Common but inadvisable for sensitive applications.
  • ·In-memory: Lost on page reload. Safest against XSS but requires a mechanism to restore the token (typically a silent refresh using an HttpOnly refresh token cookie).

Decision Guide: Which to Choose

Choose JWTs when:

  • ·You have multiple independent services that need to verify tokens — each service can verify autonomously using the public key without a shared session store
  • ·You are building a public API consumed by mobile apps, single-page applications, or third-party clients that cannot reliably use cookies
  • ·You need cross-domain SSO — a JWT issued by an auth server can be accepted by multiple services on different domains
  • ·Horizontal scaling is a priority and you want to eliminate the shared session store as a dependency
  • ·You are integrating with an external identity provider (Auth0, Okta, Cognito, Google) that issues JWTs — you have no choice in the token format

Choose server-side sessions when:

  • ·You need immediate revocation — financial transactions, admin operations, healthcare data, or any scenario where a compromised credential must be invalidated instantly
  • ·Your application is a single server or a small cluster where a shared Redis session store adds minimal complexity
  • ·You store per-session state that changes frequently and should not be encoded in every token (e.g., shopping cart contents, multi-step form progress)
  • ·Simplicity is paramount — sessions are well-understood, easy to debug, and every web framework has native session support

The Hybrid Approach

The most common production architecture for modern applications is a hybrid: short-lived JWTs for stateless API access, paired with a long-lived server-side refresh token for revocability and session management.

// Typical hybrid token architecture:

// 1. Login → issue both tokens
accessToken  = JWT, exp: 15 minutes, signed RS256
refreshToken = opaque random ID, stored in HttpOnly cookie,
               record in DB { userId, expiresAt: 30 days, used: false }

// 2. API requests use the access token (stateless, no DB lookup)
Authorization: Bearer <accessToken>

// 3. When access token expires, client silently refreshes:
POST /auth/refresh
Cookie: refresh_token=<refreshToken>

→ validate refreshToken against DB
→ issue new accessToken (+ optionally rotate refreshToken)

// 4. Logout → revoke refresh token
POST /auth/logout
→ delete refreshToken from DB
→ next refresh attempt fails → user is logged out within 15 minutes

This architecture gives you the performance benefit of stateless JWT verification on the request hot path, while retaining revocability via the refresh token. The access token lifetime determines the maximum window of continued access after revocation — typically 5–15 minutes, acceptable for most applications.

For applications that require immediate revocation (e.g., financial APIs), reduce the access token lifetime to 1–5 minutes, or implement a token blocklist for the access token as well. The shorter the access token lifetime, the more refresh requests occur — balance security requirements against the cost of additional refresh token round-trips.

Migrating from Sessions to JWTs

Migrating an existing session-based application to JWTs is non-trivial. Common pitfalls:

  • ·Don't replace sessions one-for-one: If your application stores mutable per-session state (preferences, cart), you'll need a separate store for that data — JWTs are read-only from the client's perspective.
  • ·Key management adds operational complexity: You'll need a key generation process, a JWKS endpoint, a key rotation schedule, and monitoring for key expiry. Plan this infrastructure before migrating.
  • ·Test revocation thoroughly: Identify every flow in your application that relies on immediate session invalidation (password reset, account suspension, admin revoke) and design the JWT equivalent before migrating.
  • ·Run both systems in parallel during transition: Deploy JWT issuance while keeping the session backend alive. Gradually migrate endpoints to JWT verification. Only remove the session backend once all clients are issuing and sending JWTs.
Ready to decode a token?
Use the free JWT decoder — paste any token for instant results, entirely in your browser.
Open JWT Decoder