All posts
Security

Your JWT Is Not a Session. The Costliest Misuse of OAuth in 2026.

JWTs were designed for short-lived authorization assertions. Half the industry uses them as session cookies, then discovers they cannot revoke. The five problems this causes and the right alternative.

By Sharon Sahadevan··12 min read

A web framework tutorial walks you through "JWT-based authentication." User logs in; server issues a JWT; client stores it in localStorage; client sends it in the Authorization header on every request; server validates the signature and trusts the claims. Stateless. Scalable. No database lookups per request.

Three years later you are reading a postmortem. A user reported their account compromised. The team revoked the JWT (they thought). The attacker continued to use the stolen token for another 23 hours. The team eventually figured out the JWT was self-validated by every backend, and there was no revocation list, and the only way to invalidate was to wait for expiry. The framework tutorial neglected to mention this.

JWT-as-session is one of the costliest patterns in 2020s web development. It works fine until the moment you need to revoke a token, at which point you discover the architecture has no way to do it. This post is why JWTs are not session tokens, the five problems this causes, and the right alternative.

The pattern that does not work#

The standard "JWT auth" tutorial code:

// Login handler
app.post("/login", async (req, res) => {
    const user = await authenticate(req.body.email, req.body.password);
    if (!user) return res.status(401).json({error: "invalid credentials"});
    
    const token = jwt.sign({sub: user.id, email: user.email},
                            JWT_SECRET, {expiresIn: "30d"});
    res.json({token});
});

// Protected route
app.get("/api/data", (req, res) => {
    const token = req.headers.authorization.replace("Bearer ", "");
    const claims = jwt.verify(token, JWT_SECRET);
    // ... return data ...
});

// Logout handler
app.post("/logout", (req, res) => {
    // Just tell the client to delete its token
    res.json({success: true});
});

The logout handler is the tell. There is nothing for the server to do; the JWT is self-contained; the server has no record of issuing it; "logout" is a courtesy message.

This works for the happy path. It fails for everything else.

The five problems#

Problem 1: cannot revoke#

A user reports their phone was stolen. You want to invalidate their token. You cannot.

The JWT validates against the signing key. As long as the signature checks out and exp is in the future, every backend accepts it. You have no place to write "this token is now invalid"; backends do not call any central service to ask.

Workarounds:

  • Maintain a revocation list. Backends check the list on every request. Now you have state, network calls per request, and a new failure mode (revocation list down). You have re-introduced session-store overhead while keeping JWT complexity.
  • Rotate the signing key. Invalidates ALL tokens, not just the stolen one. Every user has to re-log in.
  • Wait for the JWT to expire. Could be hours or days. Stolen token works the whole time.

None of these is good. The problem is that the architecture chose stateless self-validation specifically to avoid the revocation lookup, and now you need exactly that lookup.

Problem 2: cannot change permissions in real time#

A user is in the "admin" group. They are removed from the group at 14:00. Their existing JWT (issued at 13:00) has roles: [admin] baked in. The token is valid until expiry. They are still admin from the backend's view until then.

This is a continuous-authorization failure. The JWT is a snapshot of user state at issuance time; the backend treats it as current state. Any change to the user's roles, group memberships, or attributes does not propagate until the JWT expires and a new one is issued.

For low-stakes apps, this is acceptable (a 30-minute window between role change and effective revocation). For high-stakes apps (admin operations, financial), it is not.

Problem 3: cannot adjust session lifetime by activity#

Real session systems have idle timeout (logged out if inactive for 30 min) and absolute timeout (logged out after 8 hours regardless). The first protects against unattended-laptop scenarios; the second caps the worst case.

JWTs have one timeout: exp. There is no concept of "extend on activity" without reissuing a new JWT (which means the client has to ask the server, which means a request-per-renewal, which means... a session). Implementing sliding expiration on top of JWTs reinvents sessions badly.

Problem 4: tokens grow over time#

Real apps add claims: roles, groups, tenant ID, feature flags, region, language, theme preference. The JWT grows. Every request carries the whole JWT in headers. A 5KB JWT on every request to a static asset is not great.

The fix is server-side state: store the bulk of the user's state in a database; the cookie carries only the session ID. Now you do not have a JWT problem because you do not have a JWT.

Problem 5: token-in-localStorage = XSS pwns auth#

Browser apps that store JWTs in localStorage are vulnerable to XSS reading the token. JavaScript can access localStorage; if any XSS happens anywhere on the domain, the attacker reads the JWT.

The cookie alternative (httpOnly: true) cannot be read by JavaScript. XSS still has impact (CSRF), but the attacker cannot extract the credential.

The "we use JWTs" pattern often combines with "we put them in localStorage because that is what the tutorial said." Together, the result is worse than a session cookie would have been.

What JWTs actually were designed for#

JWTs (RFC 7519) were designed as a transport for short-lived assertions. The canonical use cases:

1. ID tokens (OIDC). Issued by the IdP at login. Consumed by the client app once to learn the user's identity. Discarded. Never sent to APIs.

2. Access tokens (OAuth). Issued for API calls. Short-lived (15-60 min). Sent to APIs. APIs validate the signature and trust the claims for the duration of the token.

3. Inter-service assertions. Service A issues a token to Service B saying "I am acting on behalf of user X." Short-lived; per-request.

In all three cases, the JWT is a capability, not a session. It says "the bearer of this token is authorized to do X for the next N minutes." Loss of the token is bounded by N. Revocation is "do not issue another one."

A session is something different: a long-lived state that represents "this user is currently logged in." Sessions need:

  • Long lifetime (hours to days).
  • Idle timeout, absolute timeout, sliding refresh.
  • Revocation on logout, password change, security event.
  • Real-time invalidation (within seconds, not "wait for expiry").

JWTs do not fit those requirements naturally. Trying to make them fit produces the five problems above.

The right pattern: short JWT access tokens + server-side session#

The architecture that scales:

1. User logs in. Server creates a session in a database (or cache):
     session_id (random 256-bit value)
     user_id
     created_at
     last_activity
     csrf_token

2. Server sets a session cookie:
     Set-Cookie: session=<session_id>; HttpOnly; Secure; SameSite=Strict;
                 Max-Age=28800; Path=/

3. Each request, server reads the cookie, looks up the session, verifies it
   is valid (not expired, not revoked, idle within threshold), gets user_id.

4. For API calls that need a JWT (e.g., calling another service), the server
   mints a short-lived JWT with the user's current claims, scoped to the
   destination, and includes it in the call. Client never sees this JWT.

5. Logout: delete the session row. All subsequent requests with this cookie
   fail at step 3. Clean revocation.

The session cookie is unused beyond identifying the row in the database. The JWT, when it appears, is a per-call short-lived assertion, not a stored credential.

This combines the strengths:

  • Sessions: revocable, idle timeout, real-time updates.
  • JWTs: stateless validation by downstream APIs, no chatty session lookups across services.

The session is at the edge (the user's web app); JWTs flow inward (between services). Each is doing its right job.

What about "stateless backends"?#

The argument for JWT-as-session is "stateless backends scale better." This is true for the JWT validation step (no DB lookup) but false for the system overall:

  • Real apps need rate limits per user, which require a per-user lookup.
  • Real apps need feature flags, A/B test bucketing, audit logging, all of which require user state.
  • Real apps need session continuity across the user's interactions, which means some database somewhere holds it.

The "stateless" claim is at most "stateless for the auth check." Once you load the user's profile, settings, permissions in the request handler (which you will), you have already done the database lookup. Avoiding one for auth specifically does not move the needle.

For services that genuinely have no per-user state (CDN edges, static API gateways), JWT validation is fine. Those services rarely need session-style behavior anyway.

What about microservices?#

The most common defense of JWT-as-session: "we have many services; they all need to validate tokens; central session lookup adds latency."

The right pattern:

  • The user-facing service (the gateway, the BFF, the web app) holds the session.
  • Internal services validate JWTs minted by the gateway.
  • The gateway mints a fresh JWT per call (or per N calls) with current scopes.
  • Services do not maintain session state; they trust gateway-minted JWTs.

This is the same pattern as the "JWT for inter-service" use case above. The user has a session; the JWT is the inter-service capability derived from the session. Both layers do what they are good at.

When it is actually OK to use a JWT as a session#

Three narrow cases where JWT-as-session is justified:

1. The session is genuinely short. A 15-minute "session" that is really an access token. At that lifetime, revocation does not matter much; expiry does the job.

2. There is no revocation requirement. Some narrow use cases (limited-time anonymous tokens, share links) genuinely do not need revocation. The token is valid until expiry; theft is bounded.

3. The application is API-only and stateless by nature. A pure API where every call is independent. No idle timeout makes sense; no per-user state to track. JWT-only auth is fine.

In all three cases, the JWT is being used as a short-lived capability, not as a session. The vocabulary matters: "this is a 15-minute access token" makes clear what it is and is not.

Migration path: from JWT-as-session to sessions + JWTs#

If you have a JWT-as-session app and want to migrate:

Phase 1: shorten JWT lifetime. Reduce from days to minutes. Add a refresh-token mechanism so users do not get logged out constantly. This alone removes most of the revocation pain (stolen tokens expire fast).

Phase 2: introduce a session for the web app. When a user logs in via OAuth, instead of returning the JWT to the client, store it server-side and return a session cookie. The client uses the cookie; the server uses the JWT internally if needed.

Phase 3: revocation endpoint. Implement /logout to delete the session. Implement /revoke for security events. Test that a deleted session immediately fails subsequent requests.

Phase 4: idle timeout, absolute timeout. Add the session policies that match the app's sensitivity.

Phase 5: drop JWT from the client side entirely. The client only ever has the session cookie. JWTs are server-internal; never exposed to the browser.

End state: a session cookie pattern with proper revocation, plus internal JWTs for service-to-service. This is what most large web apps run on.

Common arguments for JWT-as-session and the responses#

"JWTs are stateless." → Your application has state regardless. The auth check is one of the cheapest queries you do. Stateless auth does not give you stateless app.

"JWTs scale better." → Session lookup against Redis is sub-millisecond. Not the scale bottleneck.

"JWTs work across services." → Yes, as inter-service tokens minted by the gateway. The user-facing session is still better as a session cookie.

"We do not need revocation." → You will. Every app eventually does. Build the architecture to allow it.

"JWTs avoid sticky sessions." → Sticky sessions are not required for session cookies; a shared session store (Redis) works fine.

"This is what the tutorial showed." → Tutorials optimize for simplicity, not production correctness. Most are wrong about session management.

Quick reference: when JWT vs session#

Use a session (cookie + server-side state) when:
  - User-facing web app.
  - Need revocation on logout, password change, security event.
  - Need idle timeout / absolute timeout.
  - Long-lived sessions (hours to days).
  - Permission changes need to take effect quickly.

Use a JWT when:
  - Short-lived API access (OAuth access token, 15-60 min).
  - Inter-service authentication (mesh, CI to AWS).
  - Genuinely stateless capability (signed URL with embedded permissions).
  - User identity assertion (OIDC ID token, consumed once).

Use both:
  - Web app uses session cookie.
  - Web app server mints JWTs to call internal services.
  - Internal services validate JWTs offline.
  - Logout deletes the session; in-flight JWTs expire on their own.

The mental model#

JWT-as-session is a category error. JWTs were designed as ephemeral capabilities; sessions are persistent state. Treating them as interchangeable produces the five problems above and an architecture that cannot do basic things like "log out a user and have it actually take effect."

The right answer is both: sessions for the user; JWTs for the services. The session does what sessions are good at (revocable, stateful, long-lived). The JWT does what JWTs are good at (stateless, signed, short-lived). They compose; neither tries to be the other.

In 2026, if your code calls localStorage.setItem("token", jwt), that is the warning sign. Sessions belong in cookies; capabilities belong in tokens; both belong in the right place.


Session management, JWT validation, OAuth, and OIDC are covered in depth in the Identity and Trust for DevOps Engineers course. For Kubernetes-specific session and token patterns (kubectl OIDC, ServiceAccount tokens, mesh mTLS), see the Kubernetes Security course.

More in Security