Skip to content

JWT Revocation Claims — Embedding Revocation Metadata in Token Payloads

DodaTech Updated 2026-06-28 6 min read

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

  1. How does a token version claim enable stateless revocation?
  2. What is the advantage of iat-based revocation over a blocklist?
  3. When would you combine version claims with a blocklist?
  4. What happens to the version counter if the database is restored from a backup?
  5. Why should password changes trigger version increment?

Answers:

  1. 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.
  2. 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.
  3. Use version claims for bulk revocation (password change, account block) and blocklist for individual session revocation (specific device logout).
  4. 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.
  5. 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

Can version claims be forged?

No. The version is embedded in the signed JWT payload. An attacker cannot modify the version without invalidating the signature.

How often should the version counter increment?

Only on revocation events: password change, account suspension, or explicit 'revoke all sessions'. Avoid incrementing on every refresh.

Is this faster than a blocklist?

Yes. A version check reads a single user key (Redis GET) instead of checking a blocklist key. For high-traffic APIs, this reduces Redis load by 90%+.

Does this work with refresh token rotation?

Yes. Include the version claim in both access and refresh tokens. When rotated, the new tokens carry the same version until a revocation occurs.

What if I need per-device revocation?

Include a device_id claim in the token and maintain a per-device version in addition to the user-level version. This allows per-device revocation.

How do I handle the version counter overflow?

Use a string UUID or timestamp instead of a monotonically incrementing integer. UUIDs never collide and do not overflow.

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