Skip to main content

JWT Authentication - Stateless Tokens Done Right

Before you read any further, study this JWT verification code and predict the vulnerability:

import jwt

def verify_token(token: str) -> dict:
payload = jwt.decode(token, options={"verify_signature": False})
if payload.get("exp") and payload["exp"] > time.time():
return payload
raise ValueError("Token expired")

This code has three critical vulnerabilities. By the end of this lesson, you will be able to identify all three and build JWT authentication that withstands real-world attacks.

What You Will Learn

  • The internal structure of a JWT: header, payload, and signature
  • The difference between symmetric (HS256) and asymmetric (RS256) signing
  • How to issue, validate, and refresh tokens with PyJWT
  • Refresh token rotation and why it prevents token theft
  • The five most common JWT security mistakes and how to avoid them
  • Token blacklisting strategies for logout and revocation
  • How to build production-grade JWT middleware for FastAPI

Prerequisites

  • Password hashing fundamentals (Lesson 01)
  • FastAPI dependency injection and middleware (from Intermediate course)
  • Basic understanding of public-key cryptography concepts
  • pip install PyJWT cryptography

Part 1 - JWT Structure: Three Base64 Segments

A JSON Web Token is three Base64URL-encoded segments separated by dots:

eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJwYWlkIn0.signature
|___________________________________|.|_______________________________________________|.|________|
HEADER PAYLOAD SIGNATURE
import json
import base64

token = (
"eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9."
"eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJwYWlkLWluZGl2aWR1YWwiLCJleHAiOjE3MDk1MDAwMDB9."
"SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c"
)

header_b64, payload_b64, signature_b64 = token.split(".")

# Decode header
header = json.loads(base64.urlsafe_b64decode(header_b64 + "=="))
print(f"Header: {header}")
# Header: {'alg': 'HS256', 'typ': 'JWT'}

# Decode payload
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + "=="))
print(f"Payload: {payload}")
# Payload: {'sub': 'user_123', 'role': 'paid-individual', 'exp': 1709500000}

# The signature is NOT decoded - it is verified cryptographically
print(f"Signature (base64): {signature_b64}")

Standard Claims

ClaimNamePurpose
issIssuerWho issued this token (e.g., https://auth.engineersofai.com)
subSubjectWho this token is about (e.g., user ID)
audAudienceWho this token is intended for (e.g., engineersofai-api)
expExpirationUnix timestamp when the token expires
nbfNot BeforeUnix timestamp before which the token is invalid
iatIssued AtUnix timestamp when the token was issued
jtiJWT IDUnique identifier for this token (for revocation)
danger

The payload is NOT encrypted. It is only Base64-encoded. Anyone can decode and read it. Never store passwords, credit card numbers, or other secrets in the payload.

Part 2 - Signing Algorithms: Symmetric vs Asymmetric

HS256 - Symmetric (Shared Secret)

Both the issuer and verifier use the same secret key. Simple, but the secret must be shared with every service that verifies tokens:

import jwt
import time

SECRET_KEY = "your-256-bit-secret-keep-it-safe" # In production: 32+ random bytes

# Issue a token
payload = {
"sub": "user_123",
"role": "paid-individual",
"iss": "engineersofai-api",
"aud": "engineersofai-api",
"exp": int(time.time()) + 3600, # 1 hour from now
"iat": int(time.time()),
}

token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
print(f"Token: {token[:50]}...")
# Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiO...

# Verify the token
decoded = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"], # MUST specify allowed algorithms
audience="engineersofai-api",
issuer="engineersofai-api",
)
print(f"Decoded: {decoded}")
# Decoded: {'sub': 'user_123', 'role': 'paid-individual', ...}

RS256 - Asymmetric (Public/Private Key Pair)

The issuer signs with a private key. Verifiers only need the public key. This is essential for microservice architectures where you do not want to distribute the signing secret:

import jwt
import time
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization

# Generate RSA key pair (do this ONCE, store securely)
private_key = rsa.generate_private_key(
public_exponent=65537,
key_size=2048,
)
public_key = private_key.public_key()

# Serialize keys (for storage/distribution)
private_pem = private_key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.PKCS8,
encryption_algorithm=serialization.NoEncryption(),
)
public_pem = public_key.public_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PublicFormat.SubjectPublicKeyInfo,
)

# Issue a token (auth server - has private key)
payload = {
"sub": "user_123",
"role": "paid-individual",
"iss": "https://auth.engineersofai.com",
"aud": "engineersofai-api",
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
}

token = jwt.encode(payload, private_key, algorithm="RS256")

# Verify the token (API server - only needs public key)
decoded = jwt.decode(
token,
public_key,
algorithms=["RS256"],
audience="engineersofai-api",
issuer="https://auth.engineersofai.com",
)
print(f"Verified payload: {decoded['sub']}")
# Verified payload: user_123
tip

Use HS256 when a single service both issues and verifies tokens. Use RS256 when separate services issue and verify tokens (microservices, Keycloak, Auth0). RS256 is the standard for production systems.

Part 3 - Token Lifecycle: Issue, Validate, Refresh, Revoke

Access Tokens vs Refresh Tokens

PropertyAccess TokenRefresh Token
LifetimeShort (15-60 minutes)Long (7-30 days)
Sent withEvery API requestOnly to the token refresh endpoint
StorageMemory (SPA) or httpOnly cookiehttpOnly cookie or secure storage
ContainsUser claims, rolesOnly user ID and token family
RevocationExpires naturallyMust be explicitly revoked
import jwt
import time
import uuid

SECRET_KEY = "your-secret-key"
REFRESH_SECRET = "your-refresh-secret"

def issue_token_pair(user_id: str, role: str) -> dict:
"""Issue both access and refresh tokens."""
now = int(time.time())
token_family = str(uuid.uuid4()) # For refresh token rotation

access_token = jwt.encode(
{
"sub": user_id,
"role": role,
"type": "access",
"exp": now + 900, # 15 minutes
"iat": now,
"jti": str(uuid.uuid4()),
},
SECRET_KEY,
algorithm="HS256",
)

refresh_token = jwt.encode(
{
"sub": user_id,
"type": "refresh",
"family": token_family,
"exp": now + 86400 * 7, # 7 days
"iat": now,
"jti": str(uuid.uuid4()),
},
REFRESH_SECRET,
algorithm="HS256",
)

return {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer",
"expires_in": 900,
}

Refresh Token Rotation

Every time a refresh token is used, it is invalidated and a new one is issued. If an attacker steals a refresh token and the legitimate user also uses it, the system detects the reuse and invalidates the entire token family:

import jwt
import time
import uuid
from typing import Optional

# In production, this is a database table
refresh_token_store: dict[str, dict] = {}

def issue_rotated_refresh(user_id: str, family: str) -> tuple[str, str]:
"""Issue a new access + refresh token pair, rotating the refresh token."""
now = int(time.time())
new_jti = str(uuid.uuid4())

access_token = jwt.encode(
{"sub": user_id, "type": "access", "exp": now + 900, "iat": now},
SECRET_KEY,
algorithm="HS256",
)

refresh_token = jwt.encode(
{
"sub": user_id,
"type": "refresh",
"family": family,
"exp": now + 86400 * 7,
"iat": now,
"jti": new_jti,
},
REFRESH_SECRET,
algorithm="HS256",
)

# Store the new refresh token as the valid one for this family
refresh_token_store[family] = {
"jti": new_jti,
"user_id": user_id,
"used": False,
}

return access_token, refresh_token

def use_refresh_token(token: str) -> Optional[tuple[str, str]]:
"""Validate and rotate a refresh token."""
try:
payload = jwt.decode(token, REFRESH_SECRET, algorithms=["HS256"])
except jwt.InvalidTokenError:
return None

family = payload["family"]
jti = payload["jti"]

stored = refresh_token_store.get(family)
if not stored:
return None

if stored["jti"] != jti:
# REUSE DETECTED - someone used an old token
# Invalidate the entire family (force re-login)
del refresh_token_store[family]
print(f"SECURITY ALERT: Refresh token reuse in family {family}")
return None

if stored["used"]:
# This exact token was already consumed - replay attack
del refresh_token_store[family]
return None

# Mark as used and rotate
stored["used"] = True
return issue_rotated_refresh(payload["sub"], family)

Part 4 - The Five Most Common JWT Mistakes

Mistake 1: Disabling Signature Verification

# VULNERABLE - signature not verified at all
payload = jwt.decode(token, options={"verify_signature": False})
# An attacker can forge ANY payload - change role to admin, extend expiry
# FIXED - always verify signature
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])

Mistake 2: Algorithm Confusion Attack

If your server accepts "alg": "none" or allows switching between algorithm families, an attacker can forge tokens:

# VULNERABLE - accepts any algorithm the token claims to use
payload = jwt.decode(token, key, algorithms=["HS256", "RS256", "none"])
# FIXED - explicitly whitelist a single algorithm
payload = jwt.decode(token, key, algorithms=["RS256"])
# PyJWT rejects tokens signed with any other algorithm
danger

The alg: none attack: Some JWT libraries accept {"alg": "none"} in the header, meaning no signature is required. An attacker crafts a token with alg: none and an empty signature, and the server accepts it without any verification. Always specify exactly which algorithms you expect.

Mistake 3: No Expiration

# VULNERABLE - token never expires
payload = {"sub": "user_123", "role": "admin"}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# This token is valid FOREVER if stolen
# FIXED - always set expiration
payload = {
"sub": "user_123",
"role": "admin",
"exp": int(time.time()) + 900, # 15 minutes
"iat": int(time.time()),
}
token = jwt.encode(payload, SECRET_KEY, algorithm="HS256")
# PyJWT automatically rejects expired tokens during decode

Mistake 4: Sensitive Data in Payload

# VULNERABLE - secrets visible to anyone who intercepts the token
payload = {
"sub": "user_123",
"email": "[email protected]",
"ssn": "123-45-6789", # NEVER
"credit_card": "4111...", # NEVER
"password_hash": "$argon2...", # NEVER
}
# FIXED - only non-sensitive identifiers and role claims
payload = {
"sub": "user_123",
"role": "paid-individual",
"exp": int(time.time()) + 900,
"iss": "engineersofai-api",
}
# Fetch sensitive data from the database using the sub claim

Mistake 5: Not Validating Issuer and Audience

# VULNERABLE - no issuer/audience check
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
# A token issued for a different service or by a different issuer is accepted
# FIXED - validate issuer and audience
payload = jwt.decode(
token,
SECRET_KEY,
algorithms=["HS256"],
issuer="https://auth.engineersofai.com",
audience="engineersofai-api",
)
# Tokens not matching these values are rejected with InvalidIssuerError
# or InvalidAudienceError

Part 5 - Token Blacklisting Strategies

JWTs are stateless by design. Once issued, they remain valid until expiration. To support logout or forced revocation, you need a blacklist:

Strategy 1: In-Memory Set (Single Instance)

import jwt

blacklisted_jtis: set[str] = set()

def revoke_token(token: str):
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
blacklisted_jtis.add(payload["jti"])

def verify_token(token: str) -> dict:
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
if payload.get("jti") in blacklisted_jtis:
raise jwt.InvalidTokenError("Token has been revoked")
return payload

Strategy 2: Redis with Auto-Expiring Keys (Distributed)

import redis
import jwt
import time

r = redis.Redis(host="localhost", port=6379, db=0)

def revoke_token(token: str):
"""Blacklist a token in Redis. The key auto-expires when the token would have."""
payload = jwt.decode(token, SECRET_KEY, algorithms=["HS256"])
jti = payload["jti"]
exp = payload["exp"]
ttl = max(exp - int(time.time()), 0)
r.setex(f"blacklist:{jti}", ttl, "revoked")

def is_revoked(jti: str) -> bool:
return r.exists(f"blacklist:{jti}") > 0

The most practical approach for most applications:

  1. Access tokens live for 15 minutes - no blacklist needed, they expire naturally
  2. Refresh tokens are stored in a database - revocation is a simple DELETE
  3. On logout, delete the refresh token from the database
  4. The access token expires within 15 minutes maximum
tip

For most applications, short-lived access tokens + database-backed refresh tokens is the best balance of security and performance. Only add access token blacklisting if compliance requirements demand immediate revocation (e.g., financial services, SOC 2).

Part 6 - JWKS: JSON Web Key Sets

In production with RS256, public keys are distributed via a JWKS (JSON Web Key Set) endpoint. Keycloak, Auth0, and other identity providers expose this at a well-known URL:

import jwt
from jwt import PyJWKClient

# Keycloak JWKS endpoint
JWKS_URL = (
"https://auth.engineersofai.com"
"/realms/main/protocol/openid-connect/certs"
)

# PyJWT can fetch and cache JWKS automatically
jwks_client = PyJWKClient(JWKS_URL, cache_jwk_set=True, lifespan=3600)

def verify_token_with_jwks(token: str) -> dict:
"""Verify a token using the issuer's JWKS endpoint."""
# Extracts the 'kid' from the token header, fetches matching key
signing_key = jwks_client.get_signing_key_from_jwt(token)

payload = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
audience="engineersofai-api",
issuer="https://auth.engineersofai.com",
)
return payload

This approach means your API servers never store private keys. They fetch the public key from the auth server's JWKS endpoint and cache it locally.

Part 7 - Real-World: FastAPI JWT Middleware

A complete, production-grade JWT authentication system for FastAPI:

import os
import time
from typing import Annotated

import jwt
from jwt import PyJWKClient
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from pydantic import BaseModel

app = FastAPI()
security = HTTPBearer()

class AuthConfig:
JWKS_URL = os.environ["JWKS_URL"]
ISSUER = os.environ["JWT_ISSUER"]
AUDIENCE = os.environ["JWT_AUDIENCE"]
ALGORITHMS = ["RS256"]

jwks_client = PyJWKClient(
AuthConfig.JWKS_URL, cache_jwk_set=True, lifespan=3600
)

class CurrentUser(BaseModel):
user_id: str
role: str

async def get_current_user(
credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)],
) -> CurrentUser:
"""Extract and validate the JWT from the Authorization header."""
token = credentials.credentials

try:
signing_key = jwks_client.get_signing_key_from_jwt(token)
payload = jwt.decode(
token,
signing_key.key,
algorithms=AuthConfig.ALGORITHMS,
audience=AuthConfig.AUDIENCE,
issuer=AuthConfig.ISSUER,
options={"require": ["exp", "iss", "aud", "sub"]},
)
except jwt.ExpiredSignatureError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Token has expired",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidAudienceError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid audience",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidIssuerError:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Invalid issuer",
headers={"WWW-Authenticate": "Bearer"},
)
except jwt.InvalidTokenError as e:
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail=f"Invalid token: {e}",
headers={"WWW-Authenticate": "Bearer"},
)

# Extract roles from Keycloak's token structure
roles = payload.get("realm_access", {}).get("roles", [])
role = "free-user"
for r in ["admin", "paid-individual", "org-member"]:
if r in roles:
role = r
break

return CurrentUser(user_id=payload["sub"], role=role)

def require_role(required_role: str):
"""Dependency factory that enforces a specific role."""
async def role_checker(
user: Annotated[CurrentUser, Depends(get_current_user)],
) -> CurrentUser:
if user.role != required_role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail=f"Role '{required_role}' required",
)
return user
return role_checker

# --- Protected endpoints ---

@app.get("/api/me")
async def get_me(
user: Annotated[CurrentUser, Depends(get_current_user)],
):
return {"user_id": user.user_id, "role": user.role}

@app.get("/api/courses/premium")
async def get_premium_courses(
user: Annotated[CurrentUser, Depends(require_role("paid-individual"))],
):
return {"courses": ["AI Systems Engineering", "Python Intermediate"]}

@app.post("/api/admin/users")
async def admin_create_user(
user: Annotated[CurrentUser, Depends(require_role("admin"))],
):
return {"message": "User created"}

Testing JWT Middleware

import pytest
import time
import jwt as pyjwt
from unittest.mock import patch, MagicMock
from fastapi.testclient import TestClient

TEST_SECRET = "test-secret-256-bits-long-enough!"

def make_token(payload_overrides: dict = None) -> str:
"""Helper to create test JWTs."""
payload = {
"sub": "user_123",
"realm_access": {"roles": ["paid-individual"]},
"exp": int(time.time()) + 3600,
"iat": int(time.time()),
"iss": "https://auth.engineersofai.com",
"aud": "engineersofai-api",
}
if payload_overrides:
payload.update(payload_overrides)
return pyjwt.encode(payload, TEST_SECRET, algorithm="HS256")

@pytest.fixture
def mock_jwks():
"""Mock JWKS client to use test secret for HS256."""
with patch.object(jwks_client, "get_signing_key_from_jwt") as mock:
key = MagicMock()
key.key = TEST_SECRET
mock.return_value = key
# Also patch algorithms to accept HS256 in tests
with patch.object(AuthConfig, "ALGORITHMS", ["HS256"]):
yield mock

def test_valid_token(client, mock_jwks):
token = make_token()
response = client.get(
"/api/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 200
assert response.json()["user_id"] == "user_123"

def test_expired_token(client, mock_jwks):
token = make_token({"exp": int(time.time()) - 3600})
response = client.get(
"/api/me",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 401
assert "expired" in response.json()["detail"].lower()

def test_wrong_role(client, mock_jwks):
token = make_token({"realm_access": {"roles": ["free-user"]}})
response = client.get(
"/api/courses/premium",
headers={"Authorization": f"Bearer {token}"},
)
assert response.status_code == 403

Part 8 - Returning to the Opening Puzzle

The original code had three critical vulnerabilities:

  1. verify_signature: False - The signature is never verified. An attacker can forge any payload, impersonate any user, escalate to admin, or extend expiration indefinitely.

  2. Manual expiration check with no algorithm restriction - The code does not specify which algorithms are allowed. An attacker can submit a token with "alg": "none" and bypass all cryptographic verification.

  3. No issuer, audience, or required-claims validation - The code accepts tokens from any source, for any audience, with any structure. A token issued by a compromised development environment would be accepted by production.

Key Takeaways

  • JWTs have three parts: header (algorithm), payload (claims), and signature (cryptographic proof)
  • Use HS256 for single-service architectures, RS256 for multi-service or third-party auth
  • Always verify signatures, restrict algorithms, and validate issuer/audience
  • Access tokens should be short-lived (15 minutes); use refresh tokens for longer sessions
  • Refresh token rotation detects stolen tokens by invalidating the entire family on reuse
  • Never store sensitive data in JWT payloads - they are Base64-encoded, not encrypted
  • Use JWKS endpoints (Keycloak, Auth0) to distribute public keys automatically
  • For logout, combine short-lived access tokens with database-backed refresh token revocation
  • Always use options={"require": ["exp", "sub", "iss"]} to enforce mandatory claims

Graded Practice Challenges

Level 1 - Identify the Vulnerability

Question 1: What is wrong with this token verification?

decoded = jwt.decode(token, SECRET_KEY, algorithms=["HS256", "none"])
Answer

The algorithm list includes "none", which allows unsigned tokens. An attacker can send a token with {"alg": "none"} and an empty signature. The server will accept it with no cryptographic verification at all. Fix: algorithms=["HS256"] - never include "none".

Question 2: A developer stores the JWT signing key as SECRET_KEY = "mysecret" directly in their Python source code. What are the risks?

Answer

Three risks: (1) The secret is committed to version control and visible to anyone with repository access. (2) An 8-character string like "mysecret" is trivially brute-forceable - HS256 keys should be at least 256 bits (32 bytes) of random data. (3) The same secret is likely used across all environments (dev, staging, prod), meaning a compromise in development compromises production. Store the key in environment variables or a secrets manager and generate it with os.urandom(32).hex().

Question 3: An API sets access token expiration to 24 hours to "reduce refresh token complexity." What is the security impact?

Answer

A 24-hour access token means that if a token is stolen (via XSS, network interception, or log exposure), the attacker has up to 24 hours of access even after the user changes their password or the admin revokes access. With a 15-minute access token, the exposure window shrinks to 15 minutes. The operational complexity of refresh tokens is the necessary price for this drastically reduced attack surface.

Level 2 - Fix the Vulnerability

This FastAPI authentication middleware has multiple flaws. Identify and fix all of them:

import jwt
from fastapi import Request

SECRET = "secret123"

async def auth_middleware(request: Request):
token = request.headers.get("token")
if not token:
return None
try:
payload = jwt.decode(token, SECRET, algorithms=["HS256", "HS384", "RS256"])
request.state.user = payload
except:
request.state.user = None
Solution
import os
import jwt
from fastapi import Request, HTTPException, status

# Fix 1: Strong secret from environment, not hardcoded
SECRET = os.environ["JWT_SECRET_KEY"] # 32+ bytes random

async def auth_middleware(request: Request):
# Fix 2: Use standard Authorization: Bearer header
auth_header = request.headers.get("Authorization")
if not auth_header or not auth_header.startswith("Bearer "):
raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED,
detail="Missing or invalid Authorization header",
)

token = auth_header.split(" ", 1)[1]

try:
payload = jwt.decode(
token,
SECRET,
algorithms=["HS256"], # Fix 3: Single algorithm
issuer="engineersofai-api", # Fix 4: Validate issuer
options={"require": ["exp", "sub", "iss"]}, # Fix 5: Require claims
)
request.state.user = payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token expired")
except jwt.InvalidTokenError as e:
# Fix 6: Never silently swallow auth errors
raise HTTPException(status_code=401, detail=f"Invalid token: {e}")

Fixes applied: (1) Secret from environment. (2) Standard Authorization: Bearer header. (3) Single explicit algorithm. (4) Issuer validation. (5) Required claims. (6) Proper error handling instead of silent None.

Level 3 - Design a Secure System

Design a JWT authentication system for a multi-tenant SaaS platform where:

  • The auth server (Keycloak) issues tokens for multiple tenants
  • Each tenant has different roles and permissions
  • Tokens must support immediate revocation for SOC 2 compliance
  • The system handles 5000 requests/second across 10 API instances
  • Users can be logged into multiple devices simultaneously

Document your design decisions for: signing algorithm, key distribution, token lifetimes, claim structure, revocation strategy, and multi-device session handling.

Design Hints
  1. Signing: RS256 with Keycloak as the sole issuer. API servers fetch the public key via JWKS, cached with a 1-hour TTL.
  2. Token lifetimes: Access tokens = 5 minutes (short for SOC 2 compliance), refresh tokens = 24 hours, stored in database.
  3. Claims: sub (user ID), tenant_id, roles (tenant-scoped), device_id, jti, exp, iss, aud.
  4. Revocation: Redis-backed blacklist keyed by jti with TTL matching token expiration. At 5000 req/s, Redis lookup adds less than 1ms per request.
  5. Multi-device: Each device gets its own refresh token with a unique device_id. Revoking one device does not affect others. "Logout all devices" deletes all refresh tokens for the user.
  6. Key rotation: Keycloak rotates RSA keys periodically. JWKS endpoint always serves current and previous key. API servers refresh their cache when encountering an unknown kid.

What's Next

In the next lesson, OAuth 2.0 and OpenID Connect, you will learn how JWT tokens fit into the broader OAuth 2.0 authorization framework - implementing delegated authorization with Keycloak, Google, and other identity providers.

© 2026 EngineersOfAI. All rights reserved.