JWT Revocation Strategies: When Stateless Tokens Need State
I used to think JWT revocation was simple until the first incident. “User account compromised. Revoke all their tokens NOW.” The security team called at 3 AM. A user reported their account was hacked. They’d changed their password and enabled 2FA. But the attacker still had access—they had stolen a valid JWT with 24 hours of remaining lifetime. There was nothing we could do except wait for the token to expire.
This incident exposed a fundamental tension in JWT design. The whole point of JWTs is that they’re stateless and self-contained. You don’t need to hit a database to verify them. You just check the signature and expiration. But “stateless” also means “no server-side control.” Once you issue a token, it’s valid until it expires. You can’t revoke it because there’s nothing to revoke—no session to delete, no database record to update.
The JWT specification explicitly made this trade-off. Statelessness enables horizontal scaling without shared session state. It eliminates a database lookup from every request. But it assumes you can live with tokens being valid for their full lifetime. In many security-sensitive contexts, that assumption is wrong.
The solution is to add state back into a “stateless” system. This feels like it defeats the purpose of JWTs, but in practice, you’re adding a small amount of state (a denylist or version counter) instead of full session state. The verification cost goes from “check signature” to “check signature + check Redis,” but you gain the ability to revoke tokens when security requires it.
Tested on: Go 1.21, Redis 7.2, 10,000 requests/second authentication load
The JWT Revocation Problem
Why JWTs Can’t Be Revoked
JWT Design:
Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9...
┌─────────────────────────────────────────────────────────────┐
│ Header: {"alg": "HS256", "typ": "JWT"} │
│ Payload: { │
│ "sub": "user123", │
│ "exp": 1704844800, ← Expires in 24 hours │
│ "iat": 1704758400, │
│ "roles": ["admin"] │
│ } │
│ Signature: HMACSHA256(header + payload, secret) │
└─────────────────────────────────────────────────────────────┘
Verification: Check signature + check exp > now
No database lookup needed = stateless = fast
But: No way to invalidate before exp
Token leaked? Valid for 24 more hours.
When You Need Revocation
Scenarios requiring immediate revocation:
1. Account compromise
- User reports stolen credentials
- Suspicious activity detected
- Need to invalidate ALL sessions
2. Permission changes
- User demoted from admin
- Role revoked
- But token still contains old roles
3. User logout
- User clicks "Logout all devices"
- Token still valid on attacker's device
4. Compliance
- GDPR: User deletes account
- Tokens must be immediately invalid
Strategy 1: Short Expiration + Refresh Tokens
Design
Access Token: 15 minutes expiration
Refresh Token: 7 days expiration (stored server-side)
┌─────────────────────────────────────────────────────────────┐
│ Flow: │
│ │
│ 1. Login → Access Token (15m) + Refresh Token (7d) │
│ 2. API calls use Access Token │
│ 3. Access Token expires → Use Refresh Token to get new one │
│ 4. Revoke: Delete Refresh Token from database │
│ │
│ Max exposure: 15 minutes (until Access Token expires) │
└─────────────────────────────────────────────────────────────┘
Implementation
// tokens.go
type TokenService struct {
accessTTL time.Duration // 15 minutes
refreshTTL time.Duration // 7 days
db *sql.DB
jwtSecret []byte
}
func (s *TokenService) GenerateTokens(userID string) (*TokenPair, error) {
// Access token - short lived, stateless
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(s.accessTTL).Unix(),
"iat": time.Now().Unix(),
"type": "access",
})
accessStr, _ := accessToken.SignedString(s.jwtSecret)
// Refresh token - stored in database
refreshID := uuid.New().String()
_, err := s.db.Exec(
`INSERT INTO refresh_tokens (id, user_id, expires_at)
VALUES ($1, $2, $3)`,
refreshID, userID, time.Now().Add(s.refreshTTL),
)
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessStr,
RefreshToken: refreshID,
}, nil
}
func (s *TokenService) RevokeAllUserTokens(userID string) error {
// Delete all refresh tokens = user must re-login
_, err := s.db.Exec(
`DELETE FROM refresh_tokens WHERE user_id = $1`,
userID,
)
return err
}
Pros/Cons
✅ Pros:
- Simple implementation
- Limited exposure window (15 min max)
- No per-request database lookup for access token
❌ Cons:
- 15 minute window of vulnerability
- Refresh token storage adds complexity
- More client-side logic for token refresh
Strategy 2: Token Denylist (Blocklist)
Design
On revocation: Add token to Redis blocklist
On verification: Check if token is in blocklist
┌─────────────────────────────────────────────────────────────┐
│ Redis Denylist: │
│ │
│ SET revoked:<jti> 1 EX 86400 (TTL = token's remaining TTL) │
│ │
│ Verification: │
│ 1. Verify JWT signature │
│ 2. Check exp > now │
│ 3. GET revoked:<jti> → if exists, reject │
└─────────────────────────────────────────────────────────────┘
Implementation
// denylist.go
type DenylistService struct {
redis *redis.Client
jwtSecret []byte
}
func (s *DenylistService) GenerateToken(userID string, ttl time.Duration) (string, error) {
jti := uuid.New().String()
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(ttl).Unix(),
"iat": time.Now().Unix(),
"jti": jti, // Unique token ID for revocation
})
return token.SignedString(s.jwtSecret)
}
func (s *DenylistService) VerifyToken(tokenStr string) (*jwt.MapClaims, error) {
// Parse and verify signature
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
claims := token.Claims.(jwt.MapClaims)
jti := claims["jti"].(string)
// Check denylist
exists, err := s.redis.Exists(context.Background(), "revoked:"+jti).Result()
if err != nil {
return nil, err
}
if exists == 1 {
return nil, fmt.Errorf("token revoked")
}
return &claims, nil
}
func (s *DenylistService) RevokeToken(tokenStr string) error {
token, _ := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return s.jwtSecret, nil
})
claims := token.Claims.(jwt.MapClaims)
jti := claims["jti"].(string)
exp := int64(claims["exp"].(float64))
// TTL = remaining token lifetime
ttl := time.Until(time.Unix(exp, 0))
if ttl <= 0 {
return nil // Already expired
}
return s.redis.Set(
context.Background(),
"revoked:"+jti,
"1",
ttl,
).Err()
}
func (s *DenylistService) RevokeAllUserTokens(userID string, tokens []string) error {
// Must have list of all user's active tokens
// Or: use user version strategy
for _, token := range tokens {
if err := s.RevokeToken(token); err != nil {
return err
}
}
return nil
}
Pros/Cons
✅ Pros:
- Immediate revocation
- Redis lookup is fast (< 1ms)
- Denylist stays small (only revoked tokens)
❌ Cons:
- Need to track all issued tokens to revoke all
- Redis dependency for every request
- Denylist can grow large if many revocations
Strategy 3: Token Version / Generation
Design
Store "token version" per user
Include version in JWT
On revocation: Increment version
┌─────────────────────────────────────────────────────────────┐
│ Database: users.token_version = 5 │
│ │
│ JWT: {"sub": "user123", "ver": 5, "exp": ...} │
│ │
│ Verification: │
│ 1. Verify JWT signature │
│ 2. Load user.token_version from DB/cache │
│ 3. Compare JWT.ver == user.token_version │
│ 4. If not equal → token is revoked │
│ │
│ Revocation: │
│ UPDATE users SET token_version = token_version + 1 │
│ → All existing tokens immediately invalid │
└─────────────────────────────────────────────────────────────┘
Implementation
// version.go
type VersionService struct {
redis *redis.Client
db *sql.DB
jwtSecret []byte
}
func (s *VersionService) GenerateToken(userID string, ttl time.Duration) (string, error) {
// Get current version from cache/db
version, err := s.getUserVersion(userID)
if err != nil {
return "", err
}
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
"sub": userID,
"exp": time.Now().Add(ttl).Unix(),
"ver": version,
})
return token.SignedString(s.jwtSecret)
}
func (s *VersionService) VerifyToken(tokenStr string) (*jwt.MapClaims, error) {
token, err := jwt.Parse(tokenStr, func(t *jwt.Token) (interface{}, error) {
return s.jwtSecret, nil
})
if err != nil {
return nil, err
}
claims := token.Claims.(jwt.MapClaims)
userID := claims["sub"].(string)
tokenVersion := int64(claims["ver"].(float64))
// Check current version
currentVersion, err := s.getUserVersion(userID)
if err != nil {
return nil, err
}
if tokenVersion != currentVersion {
return nil, fmt.Errorf("token revoked (version mismatch)")
}
return &claims, nil
}
func (s *VersionService) RevokeAllUserTokens(userID string) error {
// Increment version → all tokens invalid
_, err := s.db.Exec(
`UPDATE users SET token_version = token_version + 1 WHERE id = $1`,
userID,
)
if err != nil {
return err
}
// Invalidate cache
return s.redis.Del(context.Background(), "user_version:"+userID).Err()
}
func (s *VersionService) getUserVersion(userID string) (int64, error) {
// Try cache first
ver, err := s.redis.Get(context.Background(), "user_version:"+userID).Int64()
if err == nil {
return ver, nil
}
// Fallback to database
var version int64
err = s.db.QueryRow(
`SELECT token_version FROM users WHERE id = $1`,
userID,
).Scan(&version)
if err != nil {
return 0, err
}
// Cache it
s.redis.Set(context.Background(), "user_version:"+userID, version, time.Hour)
return version, nil
}
Pros/Cons
✅ Pros:
- Revoke ALL user tokens with single DB update
- Small storage (one int per user)
- Works well with caching
❌ Cons:
- Requires lookup per request
- Can't revoke individual tokens
- Cache invalidation complexity
Performance Comparison
Benchmark: 10,000 requests/second, 100k users
Strategy │ Verify Latency │ Storage │ Revoke All
───────────────────────┼────────────────┼──────────────┼─────────────
Short expiration │ 0.05ms (no DB) │ Refresh: 1MB │ 1 DELETE
Denylist │ 0.2ms (Redis) │ ~10KB/day │ N SETs
Token version │ 0.3ms (cached) │ 8B/user │ 1 UPDATE
Recommendation:
- Low security: Short expiration (15 min)
- High security: Token version (immediate revocation)
- Individual revocation needed: Denylist
Checklist
## JWT Revocation Implementation
### Strategy Selection
- [ ] Assess security requirements (exposure window)
- [ ] Consider individual vs all-token revocation
- [ ] Evaluate infrastructure (Redis available?)
### Implementation
- [ ] Add jti (JWT ID) for denylist strategy
- [ ] Add ver (version) for version strategy
- [ ] Set up refresh token storage for short expiration
- [ ] Implement cache layer for version lookups
### Monitoring
- [ ] Track revocation events
- [ ] Monitor denylist size (if using)
- [ ] Alert on high verification latency
### Testing
- [ ] Test revocation flow end-to-end
- [ ] Load test verification with revocation checks
- [ ] Test cache invalidation
Conclusion
The fundamental lesson is that “stateless” authentication has limits. JWTs are excellent for many use cases—they reduce database load, enable horizontal scaling, and simplify distributed systems. But they were designed with a specific trade-off in mind: performance over control. If you need control—the ability to revoke tokens immediately—you need to add state back into the system.
The three strategies represent different points on the trade-off spectrum. Short expiration is the simplest: limit the damage window without adding infrastructure. It’s appropriate for consumer applications where a 15-minute exposure is acceptable. Denylist adds Redis but gives you per-token revocation—useful when you need surgical precision (revoke just the compromised token, not all of them). Token version is the most powerful: one database update invalidates every token the user has ever issued. It’s ideal for security-critical applications where “revoke all” is the common case.
In practice, many systems combine strategies. Use short-lived access tokens with refresh tokens (strategy 1), but also implement token versions (strategy 3) for the “revoke all” use case. The access token’s short lifetime limits exposure for most threats, while the version check catches the rare cases where immediate revocation is critical.
Key principles:
- Short expiration limits exposure but doesn’t eliminate it - 15-minute access tokens are a good default
- Denylist provides immediate revocation per token - requires Redis and tracking all issued tokens
- Token version revokes all user tokens instantly - simple implementation, one integer per user
- Combine strategies for defense in depth - short access tokens + token version is a robust pattern
Choose based on your security requirements, infrastructure constraints, and the sensitivity of your data. When in doubt, err on the side of more control.
Related Articles
- Adaptive Concurrency Limits - Performance patterns
- Redis Memory Fragmentation - Redis optimization
Related posts
Redis AOF fsync Latency Spikes: When Durability Becomes Your p99
Redis AOF can turn durability into p99 spikes: fsync pressure and rewrite fork CoW. Runbook to confirm, mitigate safely, and add guardrails.
Cache Stampede Prevention: Probabilistic Early Expiration (X-Fetch)
100 requests hit expired cache simultaneously. All 100 query the database. I implement the X-Fetch algorithm that refreshes cache before expiration without locks.
Redis Memory Fragmentation: When maxmemory Isn't Enough
Your Redis has 4GB maxmemory but RSS shows 6GB. OOM killer strikes. I explain jemalloc fragmentation with reproduction steps and activedefrag tuning.
Java Profiling in Hardened Kubernetes: When Security Blocks Your Debugger
Can't attach profiler to production JVM. seccomp blocks perf_event_open, container drops CAP_SYS_PTRACE, and PodSecurityPolicy prevents privileged mode. Here's how to profile anyway.
Cite this article
If you reference this post, please link to the original URL and credit the author.