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
Premium Content
Unlock the full article along with everything else in the archive — all in one place.