Stateless JWT vs stateful sessions: the framework I use to choose in identity systems
I was reviewing the token validation architecture of an identity backend when I found something that genuinely bothered me: the system was issuing JWTs with 24-hour expiration and there was zero revocation mechanism. If a token got compromised, the only remedy was waiting for it to expire. Twenty-four hours of window for an attacker holding a valid credential.
I asked why. The answer was the usual one: "JWT is stateless, it scales better, doesn't need a database." That line isn't wrong on its face — but nobody in that conversation could tell me what would happen the day a token actually leaked. That gap between the slogan and the incident response plan is the part that bothered me.
My thesis: stateless JWT is premature optimization in most identity systems. If you need immediate revocation or fine-grained auditing, state isn't the enemy — it's exactly what you need. The debate isn't "JWT bad, sessions good": it's about when the cost of stateless outweighs the benefit.
What the choice actually means
The dichotomy gets oversimplified way too often. Stateless JWT means the server doesn't need to query any store to validate a token: all the information is in the token itself, signed. That's genuinely valuable in certain contexts. The problem is when that design gets applied without asking what happens when something goes wrong.
With pure stateless JWT you have two levers: the expiration time (exp) and the signature. If the secret or private key hasn't been compromised, any signed and valid token is... valid. Full stop. There's no "revoke this specific token" without adding state somewhere.
Stateful sessions flip that trade-off: the server keeps the session somewhere it controls — memory, Redis, a database — and can kill it on demand. The cost moves to the store: if Redis doesn't respond, validation fails. That's a real cost that shouldn't be minimized.
The mistake isn't picking one or the other. The mistake is not asking what level of control the system you're working on actually needs.
What RFC 7009 says — and what it doesn't
RFC 7009 — OAuth 2.0 Token Revocation is the standard that defines how a client can request token revocation from an Authorization Server. It defines the /revoke endpoint, the expected parameters, and server behavior.
What the RFC explicitly says:
- The Authorization Server should revoke dependent tokens when a refresh token is revoked (section 4.1).
- Revoking a JWT access token doesn't eliminate the token from the world: it only registers that it was revoked on the server implementing the endpoint.
- The spec does not define how the Resource Server finds out a token was revoked.
That last point is the one tutorials most consistently skip. RFC 7009 solves the communication between client and Authorization Server. It does not solve the problem that a Resource Server validating JWTs in a fully stateless manner has no way of knowing that token was revoked — unless it goes and queries the Authorization Server or a shared store.
Spring Security documents this clearly in its OAuth2 Resource Server guide: default JWT validation is local (signature verification + claims like exp, nbf, iss). For active revocation you need to implement token introspection or your own blocklist mechanism.
// Default stateless JWT validation in Spring Security
// Only verifies signature, exp, iss — does NOT query any external store
http
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt
.decoder(NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.build())
)
);// For real revocation you need active token introspection
// The Resource Server queries the Authorization Server on every request
http
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspectionUri("https://auth.example.com/introspect")
.introspectionClientCredentials("client-id", "client-secret")
)
);The second option adds per-request latency. That's the honest cost of real revocation.
Where people go wrong: the hidden cost of stateless
The most common argument for stateless JWT in identity systems is scalability: "no state, no coordination between instances, horizontal scaling for free." It's a valid argument for public read APIs with short-lived tokens. For an identity system with real users, it's frequently a mirage.
Hidden cost number one: long compromise windows.
If you're issuing tokens with 1-hour-or-more expiration and no revocation, a stolen credential has a proportional attack window. In an identity system where the token grants access to sensitive operations — profile changes, document signing, access to personal data — that window matters.
Hidden cost number two: impossible auditing.
Identity systems in regulated contexts or with compliance requirements need to know which token was used, when, from which IP, for which operation. With pure stateless JWT, that information doesn't exist on the server unless you explicitly log it on every Resource Server. If you have multiple services validating the same JWT, the audit trail ends up fragmented or simply absent.
The concrete counterexample:
Imagine a system where a user reports their account was compromised. With stateful sessions in Redis, the response is immediate:
# Invalidate all active sessions for the user — immediate response
redis-cli DEL "session:user:abc123"
# Or with a pattern if you have multiple sessions per user
redis-cli --scan --pattern "session:user:abc123:*" | xargs redis-cli DELWith pure stateless JWT, the response is: "we wait for them to expire." Or you implement a blocklist — which means adding state, exactly what you were trying to avoid.
What stateless JWT actually does well:
- Short-lived tokens (minutes, not hours) with long-lived refresh tokens and refresh revocation.
- Internal service-to-service APIs where tokens don't represent user sessions.
- Contexts where introspection latency is prohibitive and the compromise risk is low.
Decision matrix: when to use each approach
Before choosing, answer these questions. They're the ones I use as a filter in any identity system design:
| Criterion | Stateless JWT | Stateful (session / token store) |
|---|---|---|
| Do you need to revoke individual tokens immediately? | ❌ Not without a blocklist | ✅ Yes |
| Do you have per-session auditing requirements? | ❌ Complex | ✅ Natural |
| Do tokens represent end-user sessions? | ⚠️ Watch out with long exp | ✅ Better fit |
| Are these short-lived machine-to-machine tokens? | ✅ Ideal | ⚠️ Unnecessary overhead |
| Is horizontal scaling without coordination critical? | ✅ Real advantage | ⚠️ Requires shared store |
| Do you have per-request latency budget for introspection? | — | ✅ Required |
The alarm checklist for stateless JWT:
[ ] Access token expiration longer than 30 minutes for end users
[ ] No documented revocation mechanism
[ ] Sensitive operations authorized by token alone (no second validation)
[ ] Session auditing required by regulation or internal policy
[ ] Multiple Resource Servers with no shared store for blocklist
If you check two or more, pure stateless is probably not the right architecture for that system.
The pattern I use most in practice: short-lived JWT (15 minutes) + opaque refresh token with state in Redis. The access token is stateless for fast per-request validation. The refresh token is stateful and revocable. The compromise window is capped at the 15-minute access token lifetime — reasonable for most scenarios.
// Typical token configuration on an Authorization Server with Spring Security
// Short access token, revocable refresh token stored in Redis
@Bean
public TokenSettings tokenSettings() {
return TokenSettings.builder()
// Short window for stateless — max revocation delay 15min
.accessTokenTimeToLive(Duration.ofMinutes(15))
// Long-lived refresh token, revocable in Redis
.refreshTokenTimeToLive(Duration.ofDays(7))
// Controlled reuse: each refresh rotates the token
.reuseRefreshTokens(false)
.build();
}This pattern appears in the OAuth 2.0 spec (RFC 6749) as recommended practice for reducing the exposure window without completely sacrificing the stateless benefit.
Common mistakes and gotchas that surface late
"The JWT has all the necessary info, I don't need anything else."
This becomes a problem when that "necessary info" changes before the token expires. Updated user roles, suspended account, org change — with stateless JWT, the info in the token can be stale for its entire lifetime.
Confusing stateless with simple.
Implementing stateless JWT correctly in an identity system requires key rotation, a JWKS endpoint, claims validation, clock skew handling, and refresh token management. It's not less code than a well-implemented session; it's different code with different failure points.
Blocklist without TTL.
If you add a blocklist for revocation, make sure entries have a TTL equal to the token's expiration time. A blocklist that grows indefinitely is a slow memory leak. Redis with EXPIRE solves this in one line:
# Add token to blocklist with TTL equal to remaining expiration time
# Assuming you calculate remaining seconds before adding
redis-cli SET "blocklist:jti:${TOKEN_JTI}" "revoked" EX ${SECONDS_UNTIL_EXP}Ignoring jti (JWT ID).
The jti claim defined in RFC 7519 is the token's unique identifier. It's what you need for an efficient blocklist. If you're not issuing it, revoking individual tokens gets much harder — you'd have to revoke by sub (user), which is more aggressive and can affect other legitimate sessions.
FAQ: JWT vs stateful sessions in identity systems
Is stateless JWT insecure by nature?
No. Stateless JWT is insecure when used in contexts where active session control is a non-negotiable requirement. The mechanism itself, correctly signed with asymmetric algorithms (RS256, ES256), is solid. The problem is the semantics of "this token is valid until it expires" in systems where you need to say "this token is no longer valid" before that moment.
Can I have the best of both worlds?
Yes, with the hybrid pattern: short-lived stateless JWT access token + opaque stateful refresh token. The cost is the added complexity of the refresh flow. Worth it in most identity systems with end users.
Doesn't token introspection solve everything?
It solves revocation, yes. The cost is a call to the Authorization Server on every validation request — additional latency that can be significant depending on volume. For high-frequency internal microservices, the cost may not be justified. For lower-frequency end-user endpoints, it's usually acceptable.
What about traditional session cookies vs JWT?
They're different mechanisms at different layers. JWT is a token format; cookies are a transport mechanism. You can transport JWT in an httpOnly+Secure cookie and get XSS protection while still using the JWT format. The "JWT vs cookies" debate usually mixes these layers and creates more confusion than clarity.
Does Spring Security support both approaches?
Yes. For stateless JWT you use the Resource Server with JWT decoder. For active introspection you use the opaque token support with the introspection endpoint. For traditional sessions, the HttpSession support with Redis or JDBC is well documented in Spring Session.
Does the architecture described in the identity architecture decisions post address this at the root?
Identity architecture decisions and the JWT vs state choice are orthogonal but related. A good identity architecture should force this question before issuing the first token, not after the system is already in production. That post covers the "what to build"; this one covers the "how to validate what you emit."
The state isn't the enemy — ambiguity is
The industry went through a "stateless everywhere" phase that led a lot of identity systems to optimize for horizontal scaling before they had any real scaling problem. The frequent result: systems that can't revoke tokens, can't audit sessions, and have no operational response when something gets compromised.
The uncomfortable part is that stateless JWT has genuine advantages. I'm not dismissing them. What I don't buy is treating "stateless" as a default setting instead of a deliberate trade-off. In identity systems — where the question "who is this user and are they still valid?" has real consequences — the cost of stateless rigidity shows up sooner than tutorials promise.
My practical recommendation: start with the hybrid pattern (short access token + opaque revocable refresh token). If store overhead is a real measured problem, look at whether you can reduce the access token TTL before eliminating state from the refresh. Pure stateless is an optimization for later, not the starting point.
The concrete next step: if you have a system issuing JWTs with expiration longer than 30 minutes and no blocklist, read RFC 7009 to understand what you still need to implement for real revocation. This isn't theory — it's the contract the OAuth ecosystem expects you to fulfill. And if you can't answer "how do we revoke this token right now" in one sentence, that's your actual bug ticket.
Related reading:
- Digital identity backend architecture: the decisions tutorials leave out
- Digital signature: format, certificate, and validation policy
- The benchmark that changed my mind about Jakarta EE in 2026
Primary sources:
- OAuth 2.0 Token Revocation RFC 7009: https://datatracker.ietf.org/doc/html/rfc7009
- Spring Security OAuth2 Resource Server: https://docs.spring.io/spring-security/reference/servlet/oauth2/resource-server/index.html
Related Articles
Cline in production: the autonomous code agent for VS Code I use with deliberate constraints
Cline can create files, run commands, and open the browser autonomously from inside VS Code. That sounds like productivity. It also smells like risk if you haven't thought through the permissions before you start. My thesis: the mental model matters more than the tool.
Aug 17 2026 · 9′ · Tutorials · TypeScript · LLM
CAdES vs XAdES Digital Signatures in Java: The Differences That Matter When Your CA Asks for One and You've Built the Other
CAdES and XAdES aren't interchangeable even though both are "advanced signatures." The choice depends on document type, trust profile, and what the CA actually expects to validate. A technical guide with DSS and Java to make the right call before you sign anything.
Aug 12 2026 · 9′ · Tutorials · seguridad · certificados
Virtual Threads Won't Save You From a Badly Placed synchronized
Virtual Threads solves the cost of spinning up thousands of threads in the JVM. It doesn't solve the blocking problem when your code has synchronized blocks or blocking native calls. JEP 444 says so, but almost nobody reads it to the end.
Aug 11 2026 · 8′ · Tutorials · concurrencia · spring-boot
Comments (0)
What do you think of this?
Drop your comment in 10 seconds.
We only use your login to show your name and avatar. No spam.
No comments yet. Be the first — your take matters most when we're few.