Post

Token Design: JWT vs Opaque Tokens

Introduction

Token format is a foundational decision for API security and scalability. JSON Web Tokens (JWTs) provide self-contained claims, while opaque tokens force introspection against an authorization server. Both approaches can be correct depending on latency, revocation, and trust boundaries.

Decision Drivers

Selecting a token design should be based on operational constraints rather than preference.

  • Latency budget: JWTs allow offline validation, opaque tokens require network calls.
  • Revocation strategy: Opaque tokens can be revoked centrally, JWTs need short TTLs or revocation lists.
  • Trust boundary: JWTs leak claims to every consumer, opaque tokens keep claims hidden.
  • Key management: JWTs require key rotation and JWKS distribution.

JWT Characteristics

JWTs are signed and optionally encrypted. They are ideal when resource servers need claims without calling the issuer.

  • Best for high-throughput APIs and low latency.
  • Require strict issuer, audience, and signature validation.
  • Must enforce short expirations and rotation for sensitive scopes.

Opaque Token Characteristics

Opaque tokens are random identifiers that are meaningless outside the issuer.

  • Best for centralized policy enforcement and immediate revocation.
  • Introspection response can include dynamic context (risk score, device trust).
  • Requires reliable network access to the authorization server.

Hybrid Models

Many systems combine both strategies.

  • Use JWTs internally between trusted services.
  • Use opaque tokens for external clients and high-risk scopes.
  • Wrap opaque tokens into session-bound caches to reduce latency.

Python Example: Local JWT Validation vs Introspection

The following Python example shows how an API can validate JWTs locally or fall back to token introspection for opaque tokens.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
import time
import jwt
import requests

JWKS_ISSUER = "https://login.example.com"
CLIENT_ID = "payments-api"
INTROSPECTION_URL = "https://login.example.com/oauth2/introspect"


def validate_jwt(token: str, public_key: str) -> dict:
    payload = jwt.decode(
        token,
        public_key,
        algorithms=["RS256"],
        audience=CLIENT_ID,
        issuer=JWKS_ISSUER,
        options={"require": ["exp", "iat", "iss", "aud"]},
    )
    return payload


def introspect_token(token: str, client_id: str, client_secret: str) -> dict:
    response = requests.post(
        INTROSPECTION_URL,
        data={"token": token},
        auth=(client_id, client_secret),
        timeout=2,
    )
    response.raise_for_status()
    data = response.json()
    if not data.get("active"):
        raise ValueError("Token inactive")
    return data

Operational Considerations

  • Use short TTLs for JWTs to limit blast radius.
  • Cache JWKS keys and handle key rotation gracefully.
  • Instrument introspection latency and error rates.
  • Avoid putting sensitive PII inside JWT claims unless encrypted.

Conclusion

JWTs and opaque tokens are not competing primitives; they are complementary tools. Choose the format based on revocation requirements, latency budgets, and whether every resource server should see the user claims.

This post is licensed under CC BY 4.0 by the author.