Build an OAuth 2.0 Authorization Server
security api-design microservices
System Design Deep Dive
OAuth 2.0 Authorization Server
PKCE for public clients, rotation that catches stolen tokens, and revocation that lands everywhere in under two seconds.
Think about how a hotel issues key cards. The front desk checks your ID once, then hands you a card that opens your room and nothing else. The card has an expiry baked in. If you lose it, the front desk can deactivate that exact card from a central system, and every door reader in the building honors the deactivation on your next swipe, not whenever it gets around to it. An OAuth 2.0 authorization server is that front desk, except the “doors” are dozens of independent microservices that cannot all phone the front desk on every swipe without melting it.
The hard part is not issuing a token. Any service can mint a signed string. The hard part is doing it for a public client that cannot keep a secret (a mobile app whose binary anyone can decompile), making the token theft-resistant for the entire session lifetime rather than just at login, and making “this token is now invalid” propagate to every resource server fast enough that a compromised credential is not still usable five minutes after it was reported stolen.
A naive implementation grants a long-lived access token at login and calls it done. That fails immediately at scale: if the token is bearer-only and never expires, stealing it once means owning the account forever. Shortening the token lifetime helps but pushes the problem into the refresh flow, where the same theft risk reappears unless the refresh token itself is rotated and monitored. And if every resource server has to call the authorization server synchronously to check if a token is still valid, you have rebuilt a single point of failure into the request path of every microservice in your fleet, undoing the entire reason you adopted JWTs in the first place.
We need to solve for three things simultaneously: an authorization flow that is safe for public clients that cannot hold a client_secret, a refresh mechanism that can prove when a token has been stolen rather than just hoping it was not, and a revocation system that reaches every resource server in seconds without putting the authorization server in the hot path of every API call.
Requirements and Constraints
Functional Requirements
- Support the authorization code grant with PKCE for public clients (SPAs, mobile apps, CLI tools) and confidential clients (server-side web apps) registered with a
client_id/client_secretpair - Issue short-lived signed JWT access tokens and longer-lived opaque refresh tokens
- Rotate refresh tokens on every use and detect reuse of a retired token as theft
- Expose a
/revokeendpoint that invalidates a token (and, for refresh tokens, its entire rotation family) and propagates that fact to all resource servers - Expose a
/jwks.jsonendpoint publishing the current and next signing public keys for stateless JWT verification - Expose a
/introspectendpoint (RFC 7662) for resource servers that hold opaque tokens or need real-time revocation status - Render a consent screen showing the requesting client’s name and the scopes it wants, and record the user’s grant decision
- Support client registration with distinct flows for confidential clients (secret issued, stored hashed) and public clients (no secret, PKCE mandatory)
Non-Functional Requirements
- Token issuance latency: p99 under 80ms for
/tokenexchanges, under 150ms for/authorizepage render - Throughput: 5,000 token issuances/second, 50,000 JWT verifications/second across all resource servers combined (verified locally, not against the auth server)
- Revocation propagation: a revoked token must be rejected by every resource server within 2 seconds of the revoke call, globally
- Availability: 99.95% for
/tokenand/authorize;/jwks.jsonmust degrade gracefully via long client-side caching even if the auth server is briefly down - Key rotation: signing keys rotate every 90 days with a 7-day overlap window where both old and new keys verify successfully
- Scale: 50 million registered users, 200,000 registered OAuth clients, supporting both web and native mobile flows
Constraints and Assumptions
- We assume TLS everywhere; OAuth 2.0 provides no protection against a non-HTTPS transport, so plaintext HTTP is out of scope
- We are building the authorization server itself, not a full identity provider with federated social login, though we mention where that would plug in
- We do not implement the implicit grant or the resource owner password credentials grant; both are deprecated in OAuth 2.1 and excluded by design
- Resource servers trust the authorization server’s signing keys but otherwise operate independently and do not share a database with it
- We assume a multi-region deployment where the authorization server’s primary datastore is regionally replicated, and revocation state is propagated via a separate low-latency channel rather than database replication
High-Level Architecture
The system has five major components: the Authorization Endpoint, the Token Service, the Revocation Bus, the JWKS / Key Management Service, and the Token Store.
The Authorization Endpoint (/authorize) handles the front-channel redirect flow. It renders the consent screen, validates the client’s redirect_uri against the registered allowlist, and for public clients, stores the code_challenge alongside the issued authorization code. The Token Service (/token) handles the back-channel exchange: it takes the authorization code (plus, for public clients, the code_verifier), validates everything, and mints an access token and refresh token pair. The Revocation Bus is a pub/sub layer, not a database, because the requirement is propagation speed, not durability of the revocation list beyond the lifetime of the tokens it covers.
The JWKS / Key Management Service holds the asymmetric signing keys (in a KMS or HSM, never in application memory as plaintext) and publishes only the public half at /jwks.json. The Token Store is the durable system of record: registered clients, issued authorization codes, refresh token families, and consent grants live in Postgres. Resource servers never query the Token Store directly. They verify JWT signatures locally against the cached JWKS and consult a small, fast deny-list (populated by the Revocation Bus) for tokens that were revoked before their natural expiry.
A request flows like this: a client redirects the user to /authorize, the user approves the requested scopes, the authorization server redirects back with a short-lived code, the client exchanges that code at /token for an access token and refresh token, and from then on the client presents the access token directly to resource servers. Resource servers verify it offline using the cached public key. When the access token expires (typically 15 minutes), the client uses the refresh token to get a new pair without re-prompting the user, and the refresh token itself is replaced on every use.
The single most important architectural decision is that JWT access tokens are verified locally by resource servers using a cached public key, never by calling back to the authorization server. This is what lets the system handle 50,000 verifications/second without the auth server being on the critical path of every API call. The cost of this design is that you cannot instantly invalidate a JWT by deleting it from a database - you need a separate, fast-propagating deny-list for the rare case of early revocation.
Component Deep Dives
The Authorization Endpoint and PKCE
This component’s job is to get explicit, auditable user consent and hand the client a short-lived, single-use code that proves that consent happened, without ever exposing a long-lived credential to a redirect URI that the OS or another app on the device might intercept.
A confidential client (a server-rendered web app) can authenticate itself at the /token endpoint with a client_secret because the secret lives on a server nobody else can read. A public client (a mobile app or single-page app) has no such place to hide a secret. Anyone can decompile the APK or read the SPA’s bundled JavaScript and extract any string baked into it. If a public client used the plain authorization code grant, an attacker who intercepted the redirect (a real risk on mobile, where custom URI schemes can be registered by multiple apps) could steal the authorization code and exchange it for tokens before the legitimate app does.
PKCE (Proof Key for Code Exchange, RFC 7636) fixes this without requiring a secret. Think of it like a claim ticket with a built-in combination lock the client invents on the spot: before redirecting to /authorize, the client generates a random code_verifier, hashes it to produce a code_challenge, and sends only the hash. The authorization server stores the hash bound to the issued code. When the client later exchanges the code at /token, it must present the original code_verifier. The server re-hashes it and compares. An attacker who only intercepted the code (and the hash, which is public) cannot produce the verifier, because the hash is one-way.
# PKCE challenge/verifier generation - runs on the client before redirect
# Demonstrates: S256 code_challenge derivation per RFC 7636
import base64
import hashlib
import secrets
def generate_pkce_pair() -> tuple[str, str]:
# code_verifier: 43-128 char unreserved-character random string
verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode("ascii")
# code_challenge: BASE64URL(SHA256(verifier)), method=S256 (never use "plain" in production)
digest = hashlib.sha256(verifier.encode("ascii")).digest()
challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
return verifier, challenge
# Authorization request:
# GET /authorize?response_type=code&client_id=mobile-app-1
# &redirect_uri=com.app.scheme://callback
# &code_challenge={challenge}&code_challenge_method=S256
# &scope=read:profile+write:orders&state={csrf_token}
# Server-side verification at /token exchange
# Demonstrates: constant-time comparison of recomputed challenge
import base64
import hashlib
import hmac
def verify_pkce(code_verifier: str, stored_challenge: str) -> bool:
digest = hashlib.sha256(code_verifier.encode("ascii")).digest()
computed_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode("ascii")
# Constant-time comparison prevents timing side-channel on the hash match
return hmac.compare_digest(computed_challenge, stored_challenge)
The consent screen itself is a deliberately boring piece of UI with high security weight: it must show the real, registered name of the requesting client (never a value the client controls unchecked, or an attacker could spoof a trusted-looking app name) and the specific scopes requested in plain language, not raw scope strings like write:orders. The user’s grant decision (which client, which scopes, when) is recorded so it can be revoked later from an account settings page, independent of any individual token’s lifecycle.
OAuth 2.1 (the consolidated successor draft) makes PKCE mandatory for all clients, not just public ones. The common mistake is treating PKCE as a mobile-only concern and skipping it for confidential clients “since they have a secret anyway.” PKCE and client authentication protect against different attacks: PKCE stops authorization code interception, client secrets prove client identity at token exchange. Require both where the client type supports it.
The Token Service and Refresh Token Rotation
This component’s job is to mint tokens and to make stolen refresh tokens detectable, not just rotate them on a timer.
The access token is a short-lived (15 minute) signed JWT containing the subject, the granted scopes, the issuing client, and an expiry. Resource servers verify it offline, so its lifetime is a direct lever on blast radius: if it leaks, it is only useful for 15 minutes. The refresh token is opaque (a random 256-bit value, not a JWT) and longer-lived (30 days), stored hashed in Postgres exactly like a password, because unlike the access token it is presented directly to the authorization server, never to a third party that needs to parse it.
Refresh token rotation means every refresh token is single-use. Each time a client calls /token with grant_type=refresh_token, the server marks the presented token as used, issues a brand-new refresh token in the same token family, and returns both the new access token and the new refresh token. A legitimate client always has the latest token in the family. If an attacker steals a refresh token from browser storage, a log file, or a misconfigured analytics SDK and uses it, one of two things happens: if they use it before the legitimate client does, the legitimate client’s next refresh attempt fails (a strong signal something is wrong) and the attacker gets in undetected on that first use. If the legitimate client refreshes first, the attacker’s stolen copy is now a used token, and replaying a used token is unambiguous proof of theft, because no legitimate client ever calls /token with a token it has already exchanged.
-- Refresh token family tracking
-- Demonstrates: single-use enforcement and theft detection via reuse of a marked token
CREATE TABLE refresh_tokens (
token_hash BYTEA PRIMARY KEY, -- SHA-256 of the raw token, never store raw
family_id UUID NOT NULL, -- shared across all generations from one login
user_id BIGINT NOT NULL REFERENCES users(id),
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id),
scope TEXT NOT NULL,
issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ, -- NULL until consumed; reuse = theft signal
replaced_by BYTEA, -- token_hash of the next generation
revoked_at TIMESTAMPTZ
);
CREATE INDEX ON refresh_tokens (family_id);
CREATE INDEX ON refresh_tokens (user_id, client_id) WHERE revoked_at IS NULL;
# Refresh token rotation with theft detection
# Demonstrates: single-use check, family-wide revocation on reuse
import hashlib
import secrets
from datetime import datetime, timedelta, timezone
def rotate_refresh_token(db, presented_token: str) -> dict:
token_hash = hashlib.sha256(presented_token.encode()).digest()
record = db.fetch_one(
"SELECT * FROM refresh_tokens WHERE token_hash = %s", (token_hash,)
)
if record is None or record["revoked_at"] is not None:
raise InvalidGrantError("refresh token unknown or revoked")
if record["used_at"] is not None:
# This exact token was already exchanged once. A second presentation
# means someone else is holding a copy. Assume theft, kill the family.
db.execute(
"UPDATE refresh_tokens SET revoked_at = NOW() WHERE family_id = %s",
(record["family_id"],),
)
publish_revocation_event(family_id=record["family_id"], reason="reuse_detected")
raise TokenTheftDetectedError(family_id=record["family_id"])
if record["expires_at"] < datetime.now(timezone.utc):
raise InvalidGrantError("refresh token expired")
# Mint the next generation in the same family
new_raw_token = secrets.token_urlsafe(32)
new_hash = hashlib.sha256(new_raw_token.encode()).digest()
db.execute(
"""INSERT INTO refresh_tokens
(token_hash, family_id, user_id, client_id, scope, expires_at)
VALUES (%s, %s, %s, %s, %s, %s)""",
(new_hash, record["family_id"], record["user_id"], record["client_id"],
record["scope"], datetime.now(timezone.utc) + timedelta(days=30)),
)
db.execute(
"UPDATE refresh_tokens SET used_at = NOW(), replaced_by = %s WHERE token_hash = %s",
(new_hash, token_hash),
)
access_token = mint_access_token(record["user_id"], record["client_id"], record["scope"])
return {"access_token": access_token, "refresh_token": new_raw_token, "expires_in": 900}
Without rotation, a single refresh token leak (a common outcome of an XSS bug, a misconfigured mobile crash reporter, or a compromised CI log) is silently usable for its entire 30-day lifetime, indistinguishable from the legitimate session. Rotation does not prevent the initial theft, but it converts theft from invisible to detectable, which is the property that actually matters operationally: it lets you force a re-login and alert the user instead of discovering the breach in a postmortem.
Auth0 and Okta both document this exact pattern under the name “refresh token rotation with automatic reuse detection.” Auth0’s implementation adds a short grace period (a few seconds) where the immediately-prior token in a family is still accepted, specifically to absorb race conditions from a single legitimate client firing two refresh requests in parallel (a common bug in apps with multiple tabs or retry logic), without that race being mistaken for theft.
The Revocation Bus and JWKS Endpoint
This component’s job is to make “this token is dead” true everywhere within the 2-second SLA without forcing every resource server to call the authorization server on every request.
Stateless JWT verification (resource servers checking a signature against a cached public key) is what gives the system its throughput, but it has a structural weakness: a JWT is valid proof of authorization until its exp claim says otherwise, even if the authorization server has since decided to revoke it. The fix is not to make every verification synchronous again. The fix is to keep a small, short-lived deny-list that only needs to hold entries for tokens that were revoked before their natural expiry, which in practice is a tiny fraction of all issued tokens.
# Revocation publish path - runs inside /revoke handler
# Demonstrates: fan-out via Redis pub/sub with a durable backing key for late subscribers
import redis
import time
r = redis.Redis(host="revocation-bus.internal", decode_responses=True)
def revoke_token(jti: str, family_id: str | None, ttl_seconds: int):
# Durable key so resource servers that reconnect after the publish still see it
r.setex(f"denylist:{jti}", ttl_seconds, "1")
if family_id:
r.setex(f"denylist:family:{family_id}", ttl_seconds, "1")
# Pub/sub for sub-second push to already-connected subscribers
r.publish("revocations", f"{jti}:{family_id or ''}:{int(time.time())}")
# Resource server side - subscriber maintains a local in-memory deny-list
# Demonstrates: hybrid push (pub/sub) + pull (periodic reconcile) for resilience
import redis
import threading
class DenyListCache:
def __init__(self, redis_client: redis.Redis):
self.redis = redis_client
self.denied_jtis: set[str] = set()
self.denied_families: set[str] = set()
self._start_subscriber()
def _start_subscriber(self):
pubsub = self.redis.pubsub()
pubsub.subscribe("revocations")
def listen():
for message in pubsub.listen():
if message["type"] != "message":
continue
jti, family_id, _ts = message["data"].split(":", 2)
if jti:
self.denied_jtis.add(jti)
if family_id:
self.denied_families.add(family_id)
threading.Thread(target=listen, daemon=True).start()
def is_revoked(self, jti: str, family_id: str | None) -> bool:
if jti in self.denied_jtis:
return True
if family_id and family_id in self.denied_families:
return True
# Fallback for messages missed during a reconnect window
return bool(self.redis.exists(f"denylist:{jti}"))
A resource server verifies a JWT in two steps: check the signature against the cached JWKS public key (fast, fully offline), then check the token’s jti against the local in-memory deny-list (also fast, populated by pub/sub push with a direct Redis fallback for the rare miss). Only the second check involves network state, and it is checking a tiny set, not a full session table.
The /jwks.json endpoint publishes the current signing key’s public half and, during a rotation window, the previous key too, each tagged with a kid (key ID) that the JWT header references. Keys rotate every 90 days; the 7-day overlap means tokens signed just before rotation still verify against the still-published old key until they naturally expire.
{
"keys": [
{
"kid": "2026-04-01-a1",
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"n": "xGOr-H7A-PWG7...",
"e": "AQAB"
},
{
"kid": "2026-06-15-b7",
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"n": "yK1m-Q9X-RTH4...",
"e": "AQAB"
}
]
}
For opaque tokens, or for resource servers that want a real-time authoritative answer instead of trusting their local cache, the /introspect endpoint (RFC 7662) provides a direct, authenticated call to the authorization server that returns active: true/false plus the token’s claims. This is slower and creates a dependency on the authorization server’s availability, so it is used selectively, for high-value operations rather than every API call.
The deny-list only ever needs to hold revoked-before-expiry tokens, which is a small set relative to total issued tokens, because the vast majority of tokens simply expire naturally and never need an entry at all. This is what keeps the deny-list cheap enough to replicate to every resource server in under two seconds. A design that tried to replicate the full set of valid sessions would not have this property.
Data Model
The data model spans four entities: registered clients, authorization codes, refresh token families (shown above), and consent grants.
-- Client registration: confidential vs public client distinction
-- Demonstrates: secret storage for confidential clients, redirect_uri allowlist
CREATE TABLE oauth_clients (
client_id TEXT PRIMARY KEY,
client_secret_hash TEXT, -- NULL for public clients
client_type TEXT NOT NULL
CHECK (client_type IN ('confidential', 'public')),
client_name TEXT NOT NULL, -- shown verbatim on consent screen
redirect_uris TEXT[] NOT NULL, -- exact-match allowlist, no wildcards
allowed_scopes TEXT[] NOT NULL,
require_pkce BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT confidential_has_secret
CHECK (client_type = 'public' OR client_secret_hash IS NOT NULL)
);
-- Authorization codes: single-use, short TTL, PKCE-bound
CREATE TABLE authorization_codes (
code_hash BYTEA PRIMARY KEY,
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id),
user_id BIGINT NOT NULL REFERENCES users(id),
redirect_uri TEXT NOT NULL,
scope TEXT NOT NULL,
code_challenge TEXT, -- NULL only for confidential w/o PKCE
code_challenge_method TEXT DEFAULT 'S256',
expires_at TIMESTAMPTZ NOT NULL, -- issued_at + 60s
used_at TIMESTAMPTZ
);
-- Consent grants: user-visible, independently revocable
CREATE TABLE consent_grants (
id BIGSERIAL PRIMARY KEY,
user_id BIGINT NOT NULL REFERENCES users(id),
client_id TEXT NOT NULL REFERENCES oauth_clients(client_id),
scope TEXT NOT NULL,
granted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
revoked_at TIMESTAMPTZ,
UNIQUE (user_id, client_id)
);
CREATE INDEX ON authorization_codes (expires_at) WHERE used_at IS NULL;
CREATE INDEX ON consent_grants (user_id) WHERE revoked_at IS NULL;
oauth_clients is partitioned logically (not physically, at this scale) by client_id, which is also the natural cache key for client lookups during /authorize and /token. authorization_codes and refresh_tokens are both sharded by hashing user_id, since nearly every query pattern (look up this user’s active tokens, revoke this user’s family) is scoped to a user, keeping cross-shard joins out of the hot path. We store hashes of codes and tokens, never the raw values, applying the same principle as password storage: a database leak should not hand out usable credentials.
A record’s lifecycle: an authorization code is born at /authorize, lives at most 60 seconds, and dies at first use (or expiry, whichever comes first) which the data flow diagram above traces end to end. A refresh token is born at /token (or at rotation), lives up to 30 days, and dies either at use (replaced by the next generation) or at revocation. A consent grant is born at first user approval and persists indefinitely until the user revokes it from account settings, independent of any individual token’s state, which is why revoking a consent_grants row must also cascade into revoking every active refresh token family tied to that (user_id, client_id) pair.
Authorization codes and refresh tokens are stored as hashes, exactly like passwords. If the Token Store leaks, the attacker gets unusable hashes, not live credentials. The raw token only ever exists in memory on the server that minted it and in the client’s possession, never at rest anywhere else.
Key Algorithms and Protocols
Token Bucket Rate Limiting per Client
The /token and /authorize endpoints are public-facing and a natural target for credential stuffing and code-guessing attacks. We rate-limit per client_id and per user_id independently using a token bucket, which tolerates bursts (a user retrying after a typo) while bounding sustained abuse.
# Token bucket rate limiter backed by Redis, atomic via Lua
# Demonstrates: O(1) check-and-decrement, no read-modify-write race
import redis
import time
LUA_TOKEN_BUCKET = """
local key = KEYS[1]
local capacity = tonumber(ARGV[1])
local refill_rate = tonumber(ARGV[2]) -- tokens per second
local now = tonumber(ARGV[3])
local requested = tonumber(ARGV[4])
local bucket = redis.call('HMGET', key, 'tokens', 'last_refill')
local tokens = tonumber(bucket[1]) or capacity
local last_refill = tonumber(bucket[2]) or now
local elapsed = math.max(0, now - last_refill)
tokens = math.min(capacity, tokens + elapsed * refill_rate)
if tokens >= requested then
tokens = tokens - requested
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 1
else
redis.call('HMSET', key, 'tokens', tokens, 'last_refill', now)
redis.call('EXPIRE', key, 3600)
return 0
end
"""
class TokenBucketLimiter:
def __init__(self, redis_client: redis.Redis, capacity: int = 20, refill_per_sec: float = 0.5):
self.redis = redis_client
self.capacity = capacity
self.refill_per_sec = refill_per_sec
self.script = self.redis.register_script(LUA_TOKEN_BUCKET)
def allow(self, identity_key: str, cost: int = 1) -> bool:
result = self.script(
keys=[f"ratelimit:{identity_key}"],
args=[self.capacity, self.refill_per_sec, time.time(), cost],
)
return bool(result)
The Lua script runs atomically inside Redis, so concurrent requests for the same client_id cannot race past each other between a read and a write, the classic bug with a naive GET/check/SET implementation under load. Time complexity is O(1) per check; space is one Redis hash per rate-limited identity, expiring after an hour of inactivity so the key space does not grow unbounded.
The property that makes the token bucket correct under concurrency is atomicity of the check-and-decrement, not the bucket algorithm itself. A sliding-window or fixed-window counter would work just as well functionally, but only if the read-modify-write is also atomic. Running the logic as a single Lua script inside Redis is what gets you that atomicity without a distributed lock.
Authorization Code and Refresh Token Generation
Both authorization codes and refresh tokens must be cryptographically unguessable, since their entire security model rests on possession being equivalent to proof of identity.
# Cryptographically secure token generation
# Demonstrates: sufficient entropy, URL-safe encoding, edge case handling
import secrets
def generate_authorization_code() -> str:
# 256 bits of entropy, base64url encoded - infeasible to guess or enumerate
# within the 60-second validity window even at high request rates
return secrets.token_urlsafe(32)
def generate_refresh_token() -> str:
return secrets.token_urlsafe(32)
def generate_jti() -> str:
# JWT ID - unique per access token, used as the deny-list key.
# Must be unique enough that two tokens never collide within any
# overlapping validity window, even across the whole fleet's issuance rate.
return secrets.token_hex(16)
secrets.token_urlsafe(32) produces 256 bits of entropy, far beyond what is brute-forceable within a 60-second authorization code lifetime even at the rate limits above. The edge case worth naming explicitly: secrets (not random) is mandatory here, since Python’s default PRNG is not cryptographically secure and has been the root cause of real-world session token prediction vulnerabilities in other systems.
The property that makes opaque tokens safe is that they are generated from a cryptographically secure random source with enough entropy that guessing is computationally infeasible within the token’s validity window. This is a much simpler property to reason about than trying to make a predictable token “safe enough” through obfuscation.
Scaling and Performance
The Authorization Endpoint and Token Service are stateless and scale horizontally behind a load balancer with no sticky sessions required, since every request carries everything needed (the code, the token, the client credentials) and all shared state lives in Postgres or Redis, not in process memory.
Capacity Estimation:
Given:
- 5,000 token issuances/second (peak)
- 50,000 JWT verifications/second across all resource servers
- 50M registered users, 200K registered clients
- Access token (JWT): ~800 bytes; refresh token row: ~250 bytes
Token issuance (writes to Postgres):
5,000/s * (1 authorization_codes row + 1 refresh_tokens row)
= 10,000 writes/sec to Token Store
Refresh token storage (30-day retention, active sessions):
Assume 30% of 50M users have an active session at any time = 15M active families
15M families * ~3 generations retained for audit * 250 bytes = ~11 GB
JWT verification (resource servers, fully local):
50,000/s * (1 signature check + 1 deny-list lookup)
No network call to auth server - bounded by local CPU + Redis RTT for deny-list
Deny-list size (revoked-before-expiry tokens only):
Assume 0.1% of active tokens are revoked early = 15,000 entries
15,000 * ~80 bytes (jti + family_id) = 1.2 MB - trivially replicated to every region
Revocation propagation bandwidth:
Peak revocation rate: ~50/sec during an incident
50/sec * 80 bytes * N regions (5) = 20 KB/s - negligible
Auth server pod sizing (c6i.2xlarge, 8 vCPU, 16GB):
~800 token issuances/sec/pod (CPU-bound on JWT signing + bcrypt verification)
Pods for 5,000/sec peak: ~7 pods, scaled to 15+ with headroom
The dominant cost is not bandwidth, it is the asymmetric cryptography on the hot path: signing JWTs (RSA or, increasingly, EdDSA for lower CPU cost) and verifying client_secret hashes with bcrypt or argon2. We cache the parsed client record (oauth_clients row) aggressively, since it rarely changes and is read on every /authorize and /token call, but we never cache the secret verification result itself, since that would defeat the purpose of hashing. Read/write ratio favors reads heavily on /jwks.json (cached for hours at the CDN edge, since key rotation is planned and infrequent) and on JWT verification (fully local to resource servers), while writes concentrate on /token and /revoke.
Google’s OAuth implementation publishes JWKS with aggressive HTTP cache headers (typically hours, not minutes) precisely because key rotation is rare and planned, and they want the long tail of resource servers across the internet to be able to verify Google-issued tokens without hammering Google’s infrastructure. The dominant bottleneck in practice is not key distribution, it is the CPU cost of asymmetric crypto operations at the issuing server, which is why Google and Okta have both moved toward EdDSA (Ed25519) signing for new deployments, which is roughly 2-3x cheaper per signature than RSA-2048 at equivalent security margins.
Failure Modes and Recovery
| Failure | Detection | Impact | Recovery |
|---|---|---|---|
| Revocation Bus (Redis pub/sub) partition | Resource server subscriber heartbeat times out | New revocations not pushed to that region; stale deny-list | Resource server falls back to periodic full deny-list pull every 5s; rejoin pub/sub on reconnect |
| Token Store (Postgres) primary failure | Health check / replication lag alarm | New /token and /authorize requests fail | Promote replica (under 30s with managed failover); idempotent retries on client side via state param |
| Signing key compromise | Manual incident detection or anomaly alert on issuance volume | Attacker can forge valid JWTs until detected | Immediately rotate signing key, publish new JWKS, treat all tokens signed since suspected compromise as compromised, force re-auth fleet-wide |
| Refresh token reuse (theft) | used_at IS NOT NULL on lookup | One compromised family; attacker has a window before detection | Revoke entire family on first detected reuse, notify user, force re-login on next request |
| Clock skew between auth server and resource servers | JWT exp/nbf validation failures spike in logs | Valid tokens rejected as expired or not-yet-valid | NTP-sync all hosts; allow a small clock skew tolerance (30-60s) in JWT validation libraries |
Consent screen client spoofing (malicious client_id) | Anomaly detection on consent denial rate for a given client | User could be tricked into approving a malicious-looking client | Verify redirect_uri exact match against registration; display only the verified registered client_name, never a request parameter |
The most common operational mistake is treating the deny-list as the only revocation mechanism and forgetting that access tokens already issued before a revocation event remain cryptographically valid until their natural expiry unless the deny-list check actually runs on every verification path. Teams sometimes add the deny-list check to the main API gateway but forget a secondary internal service that also verifies JWTs directly, leaving a silent gap where a “revoked” token still works against that one service.
Comparison of Approaches
| Approach | Latency per verification | Complexity | Failure mode | Best fit |
|---|---|---|---|---|
| Stateless JWT + deny-list (this design) | Sub-millisecond (local) + Redis RTT for deny-list (~1ms) | Medium (key rotation, deny-list propagation) | Stale deny-list during a network partition; bounded by propagation SLA | High-throughput microservice fleets where most resource servers verify constantly |
| Pure stateless JWT, no revocation | Sub-millisecond (fully local, no network) | Low | Cannot revoke before natural expiry under any circumstance | Low-risk, short-lived tokens only (under 5 minutes), where waiting out expiry is acceptable |
| Opaque token + introspection (RFC 7662) on every call | 10-50ms (network round trip to auth server every request) | Low (no key management, no deny-list infra) | Auth server becomes a hard dependency and bottleneck for every API call | Low-volume, high-security APIs where real-time revocation matters more than latency or auth-server load |
| Session cookie + server-side session store | 1-5ms (datastore lookup, often same-region) | Medium (sticky sessions or shared session store) | Session store becomes a single point of contention at scale | Traditional server-rendered web apps without cross-service token sharing needs |
| Stateless JWT with very short TTL (60-90s) and no rotation | Sub-millisecond, but requires frequent silent refresh | Medium (aggressive refresh traffic) | High refresh load; theft window still real, just smaller | Mobile apps with reliable connectivity that can tolerate frequent background refresh calls |
We would pick the stateless JWT plus deny-list approach for any system with more than a handful of resource servers, because the alternative of introspecting on every call does not scale past a few thousand requests per second without the authorization server becoming the bottleneck for the entire platform. The deny-list’s complexity cost (key management, propagation infrastructure) is worth paying once, centrally, rather than paying the latency and availability cost of a synchronous call on every request, repeated at every resource server, forever.
Key Takeaways
- PKCE removes the need for a
client_secreton public clients by replacing it with a one-time, client-generated proof that only the original requester can produce, protecting against authorization code interception without requiring a place to hide a secret. - Refresh token rotation turns token theft from invisible to detectable. The single-use constraint means a replayed token is unambiguous proof that someone besides the legitimate client is holding a copy.
- Theft detection revokes the whole family, not just the reused token, because the attacker may be holding any generation, and partial revocation leaves a gap.
- Stateless JWT verification is what lets resource servers scale independently of the authorization server, at the cost of needing a separate fast-propagating mechanism for early revocation.
- The deny-list only needs to hold revoked-before-expiry tokens, a small fraction of all issued tokens, which is what keeps cross-region propagation cheap enough to hit a sub-2-second SLA.
- JWKS with overlapping key validity lets signing keys rotate on a schedule without a hard cutover that would invalidate every in-flight token at the rotation instant.
- Consent screens must display only verified, registered client metadata, never request parameters, or the screen itself becomes a phishing vector.
- Confidential and public clients need fundamentally different trust models, not just a flag in a database; the entire token exchange validation path branches on this distinction.
The counter-intuitive lesson is that the authorization server’s biggest scaling win comes from designing it to be unnecessary on the hot path. Every architectural decision here, stateless JWTs, locally cached JWKS, a deny-list instead of a full session table, is in service of making the steady-state case (verifying a still-valid token) require zero calls to the authorization server, while keeping the rare case (something just got revoked) fast enough that it does not matter that it is the exception, not the rule.
Frequently Asked Questions
Q: Why not just use opaque tokens and introspection for everything, avoiding the JWT key management complexity entirely?
A: Introspection is simpler to build but puts the authorization server in the critical path of every single API call across every resource server. At 50,000 verifications/second, that traffic alone would require the authorization server to be sized like your largest microservice, and any latency or downtime on it now directly degrades every other service in the fleet. JWTs trade that operational risk for the complexity of key rotation and a deny-list, which is a better trade once you have more than a few resource servers.
Q: Why rotate refresh tokens at all instead of just using a shorter refresh token lifetime?
A: Shortening the lifetime reduces the theft window but does not detect theft, it just bounds the damage. A 7-day refresh token that is never rotated is still silently usable by an attacker for up to 7 days with zero signal to the legitimate user or the security team. Rotation adds detection on top of any lifetime choice, and the two are complementary, not substitutes: production systems commonly combine a 30-day rotating refresh token with 15-minute access tokens.
Q: How do you handle a public client that cannot securely store even a rotated refresh token, like a JavaScript SPA with no secure storage?
A: This is exactly why OAuth 2.1 recommends the Backend-for-Frontend (BFF) pattern for SPAs: a thin server-side component holds the refresh token in an HttpOnly cookie or server-side session, and the browser JavaScript never sees it directly. The SPA talks to its own BFF, which holds tokens on the SPA’s behalf. This sacrifices some of the “pure public client” simplicity but removes the worst attack surface, XSS-readable token storage, entirely.
Q: Why does the consent screen need a separate consent_grants table instead of just relying on refresh token state?
A: Consent is a user-facing, auditable decision (“I approved this app for these scopes”) that needs to persist and be manageable independent of any individual token’s technical lifecycle. A user revoking consent from an account settings page should immediately invalidate every refresh token family tied to that grant, but the reverse is not true: tokens expiring naturally should not erase the historical record that consent was once given, which matters for compliance and support investigations.
Q: Why cap JWT access token lifetime at 15 minutes instead of something longer like an hour to reduce refresh traffic?
A: The access token lifetime is the upper bound on how long a leaked token remains useful with zero ability to revoke it (since resource servers verify it offline). Fifteen minutes is a deliberate balance: long enough that refresh traffic does not dominate /token load, short enough that a leaked token from a logging mistake or a man-in-the-middle on a misconfigured proxy is only dangerous for a bounded, short window. Systems with higher risk tolerance for the specific scopes involved sometimes shorten this further for sensitive operations and lengthen it for low-risk read-only scopes.
Q: What stops an attacker from registering a malicious OAuth client and tricking users into granting it access?
A: Nothing in the protocol itself prevents registration of a deceptive client, this is a known limitation called consent phishing. Mitigations live outside the core token flow: verified publisher programs (requiring identity verification before a client can request sensitive scopes), scope-tiering (high-risk scopes require manual review before a client can request them), and consent screen design that clearly shows the client’s verified identity and a warning for unverified clients requesting broad access, which is exactly what Google and Microsoft’s consent screens do for third-party app integrations.
Interview Questions
Q: Walk through what happens, step by step, when a stolen refresh token is replayed by an attacker, from the moment they call /token to the user being notified.
Expected depth: Trace the full path: attacker presents the stolen (already-used) refresh token, the server looks it up by hash, finds used_at is not null, treats this as theft, marks the entire family_id as revoked in one update, publishes a revocation event to the bus for any still-valid access tokens issued under that family, and triggers an out-of-band user notification. Discuss the race condition with legitimate concurrent refreshes (multiple tabs) and how a short grace period for the immediately-prior generation avoids false positives, while still treating reuse of anything older than one generation back as theft.
Q: Design the JWKS key rotation process so that no in-flight token is ever rejected during a rotation. What is the minimum overlap window and what determines it?
Expected depth: Explain that the overlap window must be at least as long as the maximum access token lifetime (15 minutes here) plus propagation delay for /jwks.json caching, but in practice rotation windows are set much longer (days) for operational safety margin and to handle long-cached CDN copies of the old JWKS. Cover the kid header mechanism for selecting which key to verify against, generating the new key pair ahead of time, publishing both old and new public keys before switching signing to the new key, and only removing the old key from JWKS after every token signed with it has expired.
Q: How would you redesign the revocation propagation system if you needed sub-200ms propagation instead of sub-2-second, across five geographic regions?
Expected depth: Discuss the tradeoffs of moving from async pub/sub replication to synchronous quorum writes (and the latency cost that imposes on the revoke call itself, which is usually acceptable since revocation is rare and not latency-sensitive on the write side), regional Redis clusters with cross-region replication tuned for low lag versus a single global pub/sub broker, and the option of pushing revocation checks down to an edge/CDN layer in front of resource servers rather than relying on each resource server’s own subscriber. Mention that 200ms across five regions is close to physical network RTT limits for some region pairs, so the achievable floor depends on topology.
Q: A resource server team wants to skip the deny-list check entirely “since JWTs are signed and tamper-proof anyway.” How do you explain why that is wrong, and what is the actual risk?
Expected depth: Clarify the distinction between integrity (the JWT signature proves the token has not been tampered with and was genuinely issued by the authorization server) and revocation (the JWT signature says nothing about whether the authorization server has since decided this token should no longer be honored). A signed-but-revoked token is still cryptographically valid. Walk through a concrete scenario: a user’s account is compromised, support revokes all their tokens, but a resource server skipping the deny-list check continues honoring the attacker’s still-valid-by-signature access token until it naturally expires up to 15 minutes later.
Q: How would you support a third-party client that needs offline, long-running access (a backup service that syncs nightly without the user present) without keeping the user perpetually logged in?
Expected depth: Discuss the offline_access scope convention that signals a refresh token should be issued even without an interactive session, the security implications of a long-lived refresh token for an unattended client (it becomes the single thing standing between an attacker and the account, so rotation and theft detection matter even more), and operational controls like letting the user view and individually revoke long-lived grants from an account settings page, plus considering whether such a client should be confidential (server-to-server) rather than public, since an unattended backend service can hold a client_secret safely.
Premium Content
Unlock the full article along with everything else in the archive — all in one place.