Security

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.

TechViz Hub

Conceptual diagram of the JWT lifecycle from login through API validation to token refresh and revocation

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

ParameterRangeLow End EffectHigh End Effect
Access Token TTL1 min – 24 hoursVery 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 TTL1 hour – 90 daysShort 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 AlgorithmHS256, RS256, ES256HS256 (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 SizeMinimal – Full claimsMinimal 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 CheckNone – Every requestNo 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 your jwt.decode() call doesn’t specify an algorithms parameter, 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_blacklist is 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 = false returns 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

ApproachProsCons
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 requestEffective 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 HS256 signing 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

The TechViz Hub JWT lifecycle visualization showing token flow from login through API validation to refresh and revocation

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.