JWT Revocation Claims — Embedding Revocation Metadata in Token Payloads
In this tutorial, you will learn about JWT Revocation Claims. We cover key concepts, practical examples, and best practices to help you master this topic.
JWT revocation claims embed version counters and timestamps in token payloads, enabling stateless revocation by comparing token claims against current values in the database or cache.
What You'll Learn
Token version claims, issued-at revocation patterns, nonce-based invalidation, combining claims with blocklists, and implementing stateless revocation for high-throughput APIs.
Why It Matters
Blocklists require a Redis lookup for every request. Revocation claims eliminate that lookup by encoding revocation state in the token itself. The server checks a single user-level value instead of scanning a blocklist.
Real-World Use
Keycloak uses token version claims for revocation. Okta supports issued-at-based invalidation. Durga Antivirus Pro uses token version claims for internal microservice tokens, eliminating Redis lookups for 99% of requests.
flowchart LR
A["Issue JWT"] --> B["Include:\n- token_version: 3\n- iat: timestamp\n- jti: unique-id"]
B --> C["Store current\nversion in DB"]
D["API Request"] --> E["Decode JWT"]
E --> F{"token_version ==\ncurrent_version?"}
F -->|"Yes"| G["Process request"]
F -->|"No"| H["Reject — token\nrevoked"]
I["Revoke user"] --> J["Increment\ncurrent_version"]
style G fill:#dcfce7,stroke:#16a34a
style H fill:#fecaca,stroke:#dc2626
style J fill:#dbeafe,stroke:#2563eb
Code Example: Token Version Claim Revocation
import jwt, datetime, os
from flask import Flask, request, jsonify, g
app = Flask(__name__)
SECRET = os.environ.get("JWT_SECRET", "dev-secret")
# User token versions — stored in DB/cache
# Increment to revoke all tokens for a user
user_token_versions = {
"alice": 1,
"bob": 1
}
def get_current_version(user_id):
"""Fetch current token version from database/Redis."""
return user_token_versions.get(user_id, 0)
def issue_token(user_id):
current_version = get_current_version(user_id)
token = jwt.encode({
"sub": user_id,
"token_version": current_version,
"iat": datetime.datetime.utcnow(),
"exp": datetime.datetime.utcnow() + datetime.timedelta(hours=1),
"jti": os.urandom(16).hex()
}, SECRET, algorithm="HS256")
return token
@app.before_request
def authenticate():
if request.path.startswith("/api/auth/"):
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Unauthorized"}), 401
try:
payload = jwt.decode(auth[7:], SECRET, algorithms=["HS256"])
user_id = payload.get("sub")
# Stateless revocation check
current_version = get_current_version(user_id)
if payload.get("token_version", 0) < current_version:
return jsonify({
"error": "Token revoked",
"message": "A newer token version has been issued"
}), 401
g.current_user = payload
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
@app.route("/api/auth/revoke-all", methods=["POST"])
def revoke_all():
"""Revoke all tokens for the current user."""
user_id = g.current_user.get("sub")
user_token_versions[user_id] = user_token_versions.get(user_id, 0) + 1
return jsonify({"message": "All tokens revoked", "new_version": user_token_versions[user_id]})
Code Example: Issued-At (iat) Based Revocation
# Option B: Revoke by issued-at timestamp
user_revocation_times = {}
def revoke_user_sessions(user_id):
"""Revoke all sessions by setting revocation timestamp."""
user_revocation_times[user_id] = datetime.datetime.utcnow()
def is_token_valid(payload):
"""Check if token was issued before the user's revocation time."""
user_id = payload.get("sub")
revoked_at = user_revocation_times.get(user_id)
if not revoked_at:
return True
issued_at = datetime.datetime.utcfromtimestamp(payload.get("iat", 0))
return issued_at > revoked_at
@app.before_request
def auth_with_iat():
"""Auth middleware using iat-based revocation."""
if request.path.startswith("/api/auth/"):
return
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return jsonify({"error": "Unauthorized"}), 401
try:
payload = jwt.decode(auth[7:], SECRET, algorithms=["HS256"])
if not is_token_valid(payload):
return jsonify({
"error": "Session revoked",
"message": "Your session was revoked. Please log in again."
}), 401
g.current_user = payload
except jwt.ExpiredSignatureError:
return jsonify({"error": "Token expired"}), 401
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
Code Example: Hybrid Approach — Claims + Blocklist
class HybridRevocation:
"""Combines version claims with blocklist for per-token revocation."""
@staticmethod
def is_revoked(payload):
jti = payload.get("jti", "")
user_id = payload.get("sub", "")
# Fast path — check version claim
current_version = get_current_version(user_id)
if payload.get("token_version", 0) < current_version:
return True
# Slow path — check blocklist for individually revoked tokens
if is_token_blocked(jti):
return True
return False
@staticmethod
def revoke_all(user_id):
"""Bulk revocation — just increment version."""
user_token_versions[user_id] = user_token_versions.get(user_id, 0) + 1
@staticmethod
def revoke_one(jti, exp):
"""Individual revocation — add to blocklist."""
block_token(jti, exp)
# Usage in middleware
@app.before_request
def hybrid_auth():
auth = request.headers.get("Authorization", "")
if not auth.startswith("Bearer "):
return
try:
payload = jwt.decode(auth[7:], SECRET, algorithms=["HS256"])
if HybridRevocation.is_revoked(payload):
return jsonify({"error": "Token revoked"}), 401
g.current_user = payload
except jwt.InvalidTokenError:
return jsonify({"error": "Invalid token"}), 401
Common Mistakes
1. Version Claim Without Persistence
Token version increments must be persisted (database or Redis). If the server restarts and loses the version counter, revoked tokens become valid again.
2. Not Including Version in Refresh Tokens
Refresh tokens also need revocation. Include version claims in both access and refresh tokens, or use the same user-level version check for both.
3. Monotonic Version Collisions
Wrapping the version integer or resetting it creates collisions. Use a monotonic counter or UUID that never repeats.
4. Relying Only on iat Without Clock Sync
Server clocks must be synchronized (NTP). If the revocation server's clock is behind, tokens issued before revocation could still pass the check.
5. Not Revoking When Password Changes
A password change should trigger a version increment. Otherwise, the old sessions remain valid even after the password is changed.
Practice Questions
- How does a token version claim enable stateless revocation?
- What is the advantage of iat-based revocation over a blocklist?
- When would you combine version claims with a blocklist?
- What happens to the version counter if the database is restored from a backup?
- Why should password changes trigger version increment?
Answers:
- The server stores the current version per user in a fast database/cache. Older tokens with lower version numbers are rejected without a blocklist lookup.
- No Redis per-request lookup. The server checks a single user-level value instead of scanning a potentially large blocklist. This reduces latency for every request.
- Use version claims for bulk revocation (password change, account block) and blocklist for individual session revocation (specific device logout).
- If restored from a backup with an older version counter, revoked tokens become valid again. Persist version increments to a durable log or use Redis with AOF persistence.
- If the password changes and the version is not incremented, all existing sessions remain valid. An attacker with a stolen token retains access.
Challenge: Implement a hybrid revocation system with token version claims for bulk revocation and a Redis blocklist for individual token revocation. Include automatic cleanup of expired tokens.
FAQ
Mini Project
Build a Flask API with token version-based revocation. Include login, token refresh (with rotation), revoke-all endpoint, per-token blocklist for individual revocation, and a test suite that verifies revoked tokens are rejected.
What's Next
Now learn about OAuth2 Authorization Code with PKCE for securing public client authorization flows.
Built by the developers of DodaTech
Doda Browser, DodaZIP & Durga Antivirus Pro