JWT Authentication and Token Revocation Tradeoffs
Learn how JWT authentication works, why revocation is hard, and how to prevent none-alg attacks, stolen tokens, and replayed refresh tokens.

Why this matters: Sessions Don’t Scale, But Tokens Can’t Be Revoked
Traditional session-based authentication stores a session ID in a cookie and looks it up in a database (or Redis) on every request. At 10,000 requests/second, that’s 10,000 database lookups/second just for authentication.
Scale to 50 API servers behind a load balancer, and you either need sticky sessions (which break during deployments) or a shared session store (which becomes a single point of failure and a latency bottleneck).
JWTs solve the scalability problem elegantly. The token itself contains the user’s identity and permissions, signed with a secret key. Any API server can validate the token locally by checking the signature — no database lookup, no shared state, no network call. Authentication becomes a CPU operation instead of an I/O operation. Response time drops from 15ms (Redis lookup) to 0.1ms (signature verification).
But this statelessness creates a fundamental problem: you can’t revoke a JWT. Once issued, it’s valid until it expires. If a user’s token is stolen, you can’t “delete the session” because there is no session. If you fire an employee, their token still works until the exp claim passes. Every solution to this problem — blacklists, short TTLs, token rotation — reintroduces some form of state, partially negating the reason you chose JWTs in the first place.
My rule of thumb JWTs trade server lookups for revocation pain. Use them when that trade is explicit, not because “stateless” sounds simpler.
The mental model: Sign, Send, Validate — Without Touching the Database
The JWT lifecycle follows a specific flow that the interactive visualization walks through in 11 steps.
Step 1-3: Authentication. The client sends credentials (username/password) to the auth server. The auth server verifies them against the database. If valid, it creates a JWT with three parts: a header (algorithm and token type), a payload (user ID, roles, expiration time), and a signature (HMAC-SHA256 or RSA-SHA256 of the header and payload). The auth server also issues a refresh token — a long-lived opaque token stored in the database.
Step 4-5: API calls. The client includes the JWT in every API request: Authorization: Bearer <token>. The API server decodes the header, extracts the algorithm, verifies the signature using the shared secret (HMAC) or public key (RSA), then checks the exp claim. If everything passes, the request is authorized. No database call needed.
Step 6-8: Failure paths. If someone tampers with the payload (e.g., changing role: "user" to role: "admin"), the signature won’t match and the API rejects the token. If the token has expired, the exp claim check fails.
Step 9-11: Refresh and revocation. When the access token expires, the client sends the refresh token to the auth server. The auth server checks if the refresh token has been revoked (this requires a database lookup). If valid, it issues a new access token. On logout, the client deletes local tokens and the auth server revokes the refresh token.
# JWT creation and validation
import jwt
from datetime import datetime, timedelta
SECRET_KEY = "your-256-bit-secret"
# Create token
def create_access_token(user_id: str, role: str) -> str:
payload = {
"sub": user_id,
"role": role,
"iat": datetime.utcnow(),
"exp": datetime.utcnow() + timedelta(minutes=15),
}
return jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# Validate token — no database call
def validate_token(token: str) -> dict:
return jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"], # MUST specify allowed algorithms
)
Decisions that matter
| Parameter | Range | Low End Effect | High End Effect |
|---|---|---|---|
| Access Token TTL | 1 min – 24 hours | Very short-lived tokens (1-5 min). Stolen tokens are usable for minutes, not hours. But clients must refresh frequently, adding latency and auth server load. | Long-lived tokens (24h). Fewer refreshes. But a stolen token is valid for an entire day. |
| Refresh Token TTL | 1 hour – 90 days | Short refresh windows. Users re-authenticate frequently. More secure but worse UX. | Long sessions. Users stay logged in for months. Convenient but a compromised refresh token is dangerous for 90 days. |
| Signing Algorithm | HS256, RS256, ES256 | HS256 (symmetric). Simpler key management — single shared secret. But the secret must be on every API server, increasing exposure risk. | RS256/ES256 (asymmetric). Auth server holds private key; API servers only need the public key. More secure key distribution but higher CPU cost per verification (~10x). |
| Payload Size | Minimal – Full claims | Minimal payload (sub, exp, iat only). Small tokens (~200 bytes). API must query database for permissions. | Full claims (roles, permissions, org, plan). Large tokens (~2KB). No downstream queries but token bloat and exposure of user data. |
| Blacklist Check | None – Every request | No blacklist. Fully stateless. Revocation impossible until token expires. | Check blacklist on every request. Effective revocation but reintroduces the database lookup that JWTs were supposed to eliminate. |
Where it breaks: The “none” Algorithm Bypass Attack
The failure mode: The JWT spec defines an "alg": "none" option that produces unsigned tokens. An attacker crafts a JWT with "alg": "none", modifies the payload to set "role": "admin", and removes the signature.
If the API server’s JWT library calls jwt.decode(token) without explicitly specifying which algorithms are allowed, some libraries accept the none algorithm and skip signature verification entirely. The attacker gets admin access with a token they forged.
Warning The
"alg": "none"bypass is one of the most well-known JWT vulnerabilities. If yourjwt.decode()call doesn’t specify analgorithmsparameter, you are almost certainly vulnerable.
What you will notice:
- Application logs show successful authentication for users with unexpected roles or permissions
- JWT tokens in request headers have an empty or missing signature segment (the third part after the second dot is empty:
eyJ...eyJ...) - Audit logs show admin-level actions from user accounts that should only have read access
- The decoded JWT header shows
"alg": "none"instead of"HS256"or"RS256" - No corresponding login event exists in the auth server logs for the forged token
What I would do: Always specify the allowed algorithms explicitly when verifying tokens. Never allow the token’s own header to dictate which algorithm is used for verification. This is a one-line fix that prevents an entire class of attacks.
# VULNERABLE — accepts whatever algorithm the token claims
payload = jwt.decode(token, SECRET_KEY)
# SECURE — only accepts HS256, rejects "none" and algorithm switching
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
// Go (golang-jwt) — explicit algorithm enforcement
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
// Reject anything that isn't HMAC
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, fmt.Errorf("unexpected signing method: %v", token.Header["alg"])
}
return []byte(secretKey), nil
})
Where it breaks: Stolen Long-Lived Access Tokens
The failure mode: A developer sets the access token TTL to 24 hours for “better user experience.” A user’s token is leaked — through a browser extension that logs HTTP headers, a compromised CDN that caches Authorization headers, or an XSS attack that exfiltrates localStorage.
The attacker now has a valid token that works for up to 24 hours. Because the token is stateless, there’s no session to invalidate. The only way to stop the attacker is to wait for the token to expire — or rotate the signing key, which invalidates every active user’s token.
What you will notice:
- Security team detects requests from an unusual IP address or geography using a valid token
- The same token appears in requests from two different client fingerprints simultaneously
- Rate of API calls from a single user token exceeds normal human behavior (e.g., 500 requests/minute)
- The compromised user reports they haven’t been using the application
redis-cli SMEMBERS token_blacklistis empty because no blacklist mechanism exists
What I would do: Use short access token TTLs (5-15 minutes) combined with refresh token rotation. Every time the client uses a refresh token to get a new access token, the auth server issues a new refresh token and invalidates the old one.
If an attacker uses a stolen refresh token after the legitimate user has already rotated it, the auth server detects the reuse and revokes the entire token family.
Hint The visualization’s “Token Tampering” step demonstrates what happens when a token is modified. Step through stages 6-8 to see the signature verification fail in real time.
# Refresh token rotation with reuse detection
def refresh_access_token(refresh_token: str):
token_record = db.get_refresh_token(refresh_token)
if token_record is None:
raise InvalidTokenError("Token not found")
if token_record.revoked:
# Reuse detected! Revoke entire token family
db.revoke_token_family(token_record.family_id)
raise SecurityAlert("Refresh token reuse detected — all sessions revoked")
# Revoke old refresh token
db.revoke_refresh_token(refresh_token)
# Issue new tokens
new_access = create_access_token(token_record.user_id, token_record.role)
new_refresh = create_refresh_token(
user_id=token_record.user_id,
family_id=token_record.family_id # Same family for reuse detection
)
return new_access, new_refresh
Where it breaks: Refresh Token Replay Attacks
The failure mode: An attacker intercepts a refresh token from a network sniff or compromised client. The legitimate user and the attacker both hold the same refresh token. The legitimate user refreshes first — the auth server issues a new access token and a new refresh token.
Now the attacker tries to use the old refresh token. If the auth server simply checks “does this refresh token exist and is it not expired?”, it issues another access token to the attacker, because the old refresh token was never explicitly revoked — the server just issued a new one alongside it.
What you will notice:
- Auth server logs show the same refresh token being used from two different IP addresses
- Multiple active access tokens exist for the same user with overlapping validity windows
SELECT COUNT(*) FROM refresh_tokens WHERE user_id = ? AND revoked = falsereturns more active tokens than expected- The user reports being “logged out” intermittently (their tokens are being rotated by the attacker’s requests)
- Security monitoring shows token refresh patterns inconsistent with normal client behavior
What I would do: Implement a token blacklist backed by Redis with a TTL equal to the refresh token’s expiration time. When a refresh token is used, immediately add it to the blacklist. Before issuing a new access token, check the blacklist.
For defense in depth, group tokens into families: if any token in a family is reused after being blacklisted, revoke the entire family.
# Redis-backed token blacklist
import redis
r = redis.Redis(host="localhost", port=6379, db=0)
def is_token_blacklisted(token_id: str) -> bool:
return r.sismember("token:blacklist", token_id)
def blacklist_token(token_id: str, ttl_seconds: int):
r.sadd("token:blacklist", token_id)
r.expire("token:blacklist", ttl_seconds)
# In the refresh endpoint:
def refresh(refresh_token: str):
token_id = decode_token_id(refresh_token)
if is_token_blacklisted(token_id):
revoke_token_family(token_id) # Nuclear option
raise SecurityError("Token replay detected")
blacklist_token(token_id, ttl_seconds=86400)
return issue_new_tokens(refresh_token)
The tradeoff
| Approach | Pros | Cons |
|---|---|---|
| Short access token TTL (5 min) | Stolen tokens expire quickly. Small blast radius. | Frequent refresh requests. Auth server load increases. Clients need robust refresh logic. |
| Long access token TTL (24h) | Fewer refresh cycles. Simpler client code. Lower auth server load. | Stolen tokens are valid for an entire day. No way to revoke without infrastructure. |
| Fully stateless (no blacklist) | Maximum scalability. Zero database calls for auth. Horizontal scaling trivial. | Cannot revoke tokens until expiration. Compromised tokens are unstoppable. |
| Redis blacklist on every request | Effective token revocation. Can invalidate tokens instantly. | Reintroduces a network lookup per request (1-2ms). Redis becomes a critical dependency. |
| Symmetric signing (HS256) | Fast signing and verification (~5μs). Simple key management. | Secret key must exist on every API server. Key rotation requires coordinated deployment. |
| Asymmetric signing (RS256) | API servers only need the public key. Safer key distribution. | Slower verification (~100μs). More complex key management (JWKS endpoints, key rotation). |
| Large payload (embedded claims) | No downstream database queries. API has all authorization data locally. | Token size grows (1-2KB+). Sensitive data in payload (base64 encoded, not encrypted). Stale claims if user’s permissions change mid-token. |
Hint For most production APIs, a 15-minute access token TTL with
HS256signing and a Redis blacklist provides a strong balance of security, performance, and operational simplicity.
A practical checklist
Step 1: Enforce algorithm validation. Audit every jwt.decode() or jwt.verify() call in your codebase. Ensure the algorithms parameter is explicitly set. Never allow the token to specify its own algorithm. This prevents the “none” algorithm bypass and algorithm confusion attacks.
Step 2: Set access token TTL to 15 minutes or less. This limits the blast radius of stolen tokens to minutes instead of hours. Accept the tradeoff of more frequent refresh cycles. Implement transparent token refresh in your API client SDK so the UX impact is invisible.
Step 3: Implement refresh token rotation. Every time a refresh token is used, issue a new refresh token and revoke the old one. Group tokens into families for reuse detection. If a revoked refresh token is used again, revoke the entire family — this is the signal that the token was stolen.
Step 4: Add a Redis-backed blacklist for critical systems. For systems where immediate revocation is required (admin panels, financial APIs), add a Redis SET check to your validation middleware. The overhead is ~1ms per request. Set key TTL to match your access token TTL so the blacklist self-cleans.
Step 5: Monitor token usage patterns. Log the jti (JWT ID), source IP, and user agent for every token validation. Alert when the same token is used from multiple IPs simultaneously, or when refresh frequency exceeds normal thresholds (e.g., more than 20 refreshes per hour per user).
# Monitor active token usage in Redis
redis-cli SCARD token:blacklist # Number of revoked tokens
redis-cli TTL token:blacklist # Time until blacklist entry expires
redis-cli SMEMBERS token:blacklist # List all revoked token IDs

Try it yourself
The visualization walks you through all 11 stages of the JWT lifecycle. Watch the client authenticate with the auth server, receive a signed token, and use it for stateless API calls. See what happens when a token is tampered with — the signature check fails.
Observe the refresh flow and understand why revoking a refresh token doesn’t immediately invalidate the access token. Step through each stage with Previous/Next, replay individual steps, and adjust animation speed from 0.25x to 2x.
Try the interactive JWT Lifecycle visualization → Step through login, signing, bearer authentication, token tampering, expiration, refresh flows, and logout to build a complete mental model of how stateless authentication works — and where it breaks.