Back to blog

JWT Revocation Strategies: When Stateless Tokens Need State

|
| security, jwt, authentication, redis, performance, auth

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:

  1. Short expiration limits exposure but doesn’t eliminate it - 15-minute access tokens are a good default
  2. Denylist provides immediate revocation per token - requires Redis and tracking all issued tokens
  3. Token version revokes all user tokens instantly - simple implementation, one integer per user
  4. 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 posts

Cite this article

If you reference this post, please link to the original URL and credit the author.

Michal Drozd. "JWT Revocation Strategies: When Stateless Tokens Need State". https://www.michal-drozd.com/en/blog/jwt-revocation-strategies/ (Published October 12, 2025).