What Is a JWT - and When a Plain Session Is Still the Better Choice
A JWT (JSON Web Token) is a signed, self-contained blob of identity data that your server can verify without touching a database. The question "why use a JWT instead of a session" comes up in almost every auth design review, and the answer is less about which one is better and more about which tradeoff you can live with.
In a traditional server-side session, your app stores session state in memory or a datastore, hands the client an opaque session ID in a cookie, and looks up that ID on every request. A JWT flips this: the server encodes the user's identity and claims into a token, signs it, and never stores it. The client sends the token back on each request, and any server that has the signing key can verify it without a shared session store.
This distinction sounds small, but it changes how you think about logout, horizontal scaling, and cross-service trust. The rest of this page walks through the mechanism and, critically, the cases where a session cookie is the right call.
| Concept | JSON Web Token (JWT) |
|---|---|
| Where it sits | Authentication & Authorization — sits between your identity provider and every service that needs to know who the caller is |
| In one sentence | A signed token that carries identity claims so the receiving service can verify the caller without a database lookup. |
The 60-Second Answer
A JWT encodes a user's identity and permissions into a signed token the client carries. Any service with the verification key can trust the token without calling back to an auth server or hitting a session store. The one problem it solves: eliminating the shared session datastore that every service must query on every request. The one cost it adds: you lose the ability to instantly revoke access, because the token is valid until it expires — there is no server-side record to delete.
How It Actually Works
The mechanism, step by step
- 1. The server encodes claims into a three-part token
- 2. The signature lets any holder of the key verify without a round-trip
- 3. Expiration is baked into the token itself
- 4. Short-lived access tokens pair with longer-lived refresh tokens
- 5. The token travels in a header or a cookie — each has consequences
- 6. Cross-service trust works without a shared session store
- 7. Revocation requires building back the state you removed
1. The server encodes claims into a three-part token
Header, payload, signature — that is the whole format
A JWT is three Base64url-encoded segments separated by dots. The header declares the signing algorithm (HS256, RS256, etc.). The payload carries claims — standard ones like sub (subject), exp (expiration), iat (issued at), plus any custom claims you add (roles, tenant ID, whatever). The signature is the header and payload signed with a secret (symmetric) or a private key (asymmetric).
{
"alg": "RS256",
"typ": "JWT"
}
The payload is not encrypted — anyone can decode and read it. The signature only guarantees it has not been tampered with.
⚠️ In production — Engineers routinely put sensitive data in the payload thinking it is opaque. It is not. If you Base64-decode a JWT in your browser console, you will see everything. Never put passwords, SSNs, or internal IDs you would not want a user to see.
2. The signature lets any holder of the key verify without a round-trip
Verification is a local math operation
When a request arrives, the receiving service takes the header and payload, re-computes the signature using the key it holds, and compares. If the signatures match, the token is authentic. With symmetric signing (HMAC), every service shares the same secret. With asymmetric signing (RSA or ECDSA), the auth server signs with a private key and every downstream service verifies with the corresponding public key.
This is the core reason JWTs exist: verification is a CPU operation, not a network call.
⚠️ In production — Symmetric signing means any service that can verify can also forge tokens. In a microservices architecture, asymmetric signing is almost always what you want — it limits the blast radius of a compromised service to reading claims, not minting new ones.
3. Expiration is baked into the token itself
The exp claim is the only revocation you get for free
The exp claim is a Unix timestamp. When the receiving service checks the signature, it also checks whether the current time is past exp. If it is, the token is rejected.
This is the mechanism that replaces the session timeout you would get from a server-side session store. The difference: a session timeout is enforced by deleting the record. A JWT timeout is enforced by the clock on the verifying server, and nothing can make the token invalid before that time without extra infrastructure.
⚠️ In production — Clock skew between servers can silently extend or shorten token lifetimes. In distributed systems, a few seconds of NTP drift can cause tokens to be accepted on one service and rejected on another. Most JWT libraries accept a configurable clock tolerance — set it explicitly rather than relying on the default.
4. Short-lived access tokens pair with longer-lived refresh tokens
The two-token pattern controls the damage window
Because you cannot revoke a JWT once issued, the standard mitigation is to keep the access token lifetime short — minutes, not hours. When it expires, the client uses a refresh token to get a new access token from the auth server. The refresh token is typically stored server-side (or in a secure, httpOnly cookie), so it can be revoked.
This gives you the best of both: downstream services verify the access token locally with no database call, and the auth server can cut off a user by refusing to issue new access tokens when the refresh token is presented.
Client → Auth server: here is my refresh token
Auth server → checks revocation list → issues new short-lived JWT
Client → API: here is my new access token
API → verifies signature + exp locally → serves request
⚠️ In production — If your access token lifetime is 15 minutes and your refresh token lifetime is 7 days, a compromised access token is useful for at most 15 minutes — but a compromised refresh token is useful for 7 days. Protect refresh tokens at least as carefully as you would a session ID.
5. The token travels in a header or a cookie — each has consequences
Bearer header vs. cookie changes your threat model
The most common transport is the Authorization: Bearer <token> header, which the client sets explicitly on each request. This works well for SPAs and mobile apps calling APIs. Alternatively, you can put the JWT in a httpOnly, Secure, SameSite cookie, which the browser sends automatically.
Bearer headers are immune to CSRF (the browser does not attach them automatically to cross-origin requests) but vulnerable to XSS (if JavaScript can read localStorage, it can steal the token). Cookies are vulnerable to CSRF (mitigated by SameSite and anti-CSRF tokens) but immune to XSS if marked httpOnly.
⚠️ In production — Storing JWTs in localStorage is common in tutorials and dangerous in production. Any XSS vulnerability — even in a third-party script you included — gives the attacker a long-lived credential they can exfiltrate and use from any machine. httpOnly cookies are harder to steal because JavaScript cannot read them.
6. Cross-service trust works without a shared session store
This is where JWTs actually earn their complexity
In a monolith, a session store is a single Redis or database table. In a microservices architecture with five or ten services, every service either needs access to that store (coupling and latency) or needs to call the auth service on every request (same problem, different shape).
With JWTs, the auth service signs the token once. Every downstream service verifies it independently using the public key. No shared state, no network call to a central authority on the hot path. This is the scenario where the JWT model genuinely pays for its complexity.
⚠️ In production — The public key itself still needs to be distributed. Most setups use a JWKS (JSON Web Key Set) endpoint that services poll or cache. If the JWKS endpoint goes down and your cached keys expire, every service rejects every token simultaneously. Cache JWKS aggressively and monitor the endpoint.
7. Revocation requires building back the state you removed
The hardest operational problem with JWTs
If a user logs out, changes their password, or gets banned, you need to invalidate their existing tokens. But the whole point of a JWT is that there is no server-side record to delete. The common solutions all re-introduce some form of shared state:
- Token blocklist: store revoked token IDs (
jticlaim) in a fast store like Redis and check on every request. This is literally a session store with different semantics. - Short expiration: keep tokens so short-lived that revocation is unnecessary — the user is locked out within minutes.
- Version counter: store a per-user token version in the database; reject tokens whose version is older.
Every one of these trades back some of the statelessness you chose JWTs to get.
⚠️ In production — Teams that need instant revocation (financial apps, admin tools, anything where a compromised account must be locked out in seconds) often discover that the blocklist they build is operationally identical to the session store they were trying to avoid. If instant revocation is a hard requirement, evaluate honestly whether a JWT is actually saving you anything.
- A mobile app backed by a REST API uses JWTs because the client is not a browser — there are no cookies in the traditional sense. The app stores the access token in secure storage, sends it as a Bearer header, and uses a refresh token to rotate it. Each API server verifies the token without sharing session state.
- A company runs a microservices backend with an API gateway. The gateway validates the JWT once, then forwards it to downstream services. Each service reads the claims (user ID, roles, tenant) without calling back to the auth service. The token is the trust boundary between services.
- An SSO (single sign-on) system across three internal web apps issues a JWT from the identity provider. Each app verifies the token independently. The user logs in once and the token carries their identity to all three apps without any of them sharing a session database.
- A serverless function (Lambda, Cloud Function) handles API requests. There is no persistent process to hold session state, and spinning up a database connection on every cold start is expensive. A JWT lets the function verify identity from the token alone, with no external call.
When You Actually Need It
- You have multiple services that need to verify the caller's identity, and you want to avoid coupling them all to a shared session store or a central auth service on every request.
- You are building a stateless API consumed by mobile apps, SPAs, or third-party clients where server-side cookie sessions are awkward or impossible.
- You need to pass identity and claims across trust boundaries — between services, between an API gateway and backends, or between organizations in a federation.
- Your infrastructure is serverless or ephemeral, where maintaining persistent session state is expensive or architecturally painful.
- You need cross-domain authentication (multiple subdomains or entirely separate domains) and cookies do not span the boundary cleanly.
When You Don't
- You are building a server-rendered web app with a single backend. A session cookie backed by Redis or an in-memory store is simpler, gives you instant revocation, and your framework almost certainly has built-in middleware for it. The JWT adds complexity for zero benefit.
- Your app has a hard requirement for instant logout or instant revocation — banning a user, force-logging out a compromised account. You will end up building a token blocklist, which is a session store with extra steps.
- You have fewer than three services and they all already talk to the same database. The 'no shared state' benefit of JWTs does not exist if your services already share a database — just put your sessions in that database.
- You are early-stage and your entire system runs on a single server or a small cluster. Horizontal scaling problems are real but they are not your problem yet. A session store in Redis handles thousands of concurrent users trivially. Do not optimize for a scale you have not reached.
- You want to store large amounts of session data (shopping cart contents, multi-step form state, user preferences). JWTs get bigger with every claim you add, and that payload ships on every single request. Session IDs are tiny; the data stays server-side.
What People Get Wrong
"JWTs are more secure than sessions."
JWTs and sessions have different threat surfaces, not different security levels. JWTs are vulnerable to token theft via XSS if stored in localStorage, and they cannot be revoked instantly without extra infrastructure. Sessions are vulnerable to CSRF and require a reliable session store. The security depends on your implementation, not the token format.
"JWTs are encrypted, so the payload is private."
Standard JWTs (JWS) are signed, not encrypted. The payload is Base64url-encoded, which is trivially reversible. Anyone who intercepts the token can read every claim. If you need payload confidentiality, you need JWE (JSON Web Encryption), which is a different spec and adds significant complexity.
"JWTs make your system stateless."
The access token verification is stateless. But the moment you need refresh tokens, revocation, or a JWKS endpoint, you have reintroduced server-side state. JWTs shift where the state lives and reduce how often it is consulted, but they do not eliminate it.
"You should use JWTs for everything once you adopt them."
JWTs are a mechanism for cross-boundary identity verification. Using them as a general-purpose session store — stuffing cart data, UI preferences, and feature flags into the payload — bloats every request and misuses the format. Keep JWTs thin: identity and authorization claims only.
Why the Design Trades Revocation for Statelessness
The core engineering constraint behind JWTs is the n-service verification problem. In a system with one server, looking up a session ID in a local store is trivially fast. But when requests fan out across many services — an API gateway, an order service, an inventory service, a notification service — every one of them needs to answer the question "who is this caller and what are they allowed to do?" A shared session store becomes a single point of failure and a latency bottleneck on every request path.
The JWT design trades away instant revocation to eliminate that bottleneck. By making the token self-verifying, it turns an O(n) network-call problem into an O(1) CPU problem at each service. The signature is the proof. The tradeoff is that proof has a time-to-live — once issued, the token is valid until it expires, and the issuer has no built-in way to recall it. This is not a bug; it is the cost of removing the central authority from the hot path.
Every mitigation for revocation — blocklists, short expiration windows, version counters — reintroduces some fraction of the shared state the design was trying to avoid. The engineering skill is in choosing the right fraction: short-lived access tokens with server-side refresh tokens give most systems a workable middle ground, where the access token is fully stateless and the refresh flow is the only point that touches a store. If your system cannot tolerate even a few minutes of delay between "revoke this user" and "this user is actually locked out", the JWT model is fighting you, and a session store with pub/sub invalidation is probably the more honest architecture.
Related Briefs
What to Read Next
- OAuth 2.0 authorization code flow
- session management best practices
- OpenID Connect ID tokens
Use a JWT when you have multiple services that need to independently verify a caller's identity without sharing a session store — that is the specific problem it was designed to solve. If you have a single backend, a monolith, or a hard requirement for instant revocation, a server-side session is simpler and gives you capabilities that JWTs make you rebuild from scratch. The right question is not "which is better" but "do I actually have the distributed verification problem that justifies the revocation tradeoff."
댓글
댓글 쓰기