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.

⏱ 14 min read 📐 Advanced 🔐 Security

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_secret pair
  • 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 /revoke endpoint that invalidates a token (and, for refresh tokens, its entire rotation family) and propagates that fact to all resource servers
  • Expose a /jwks.json endpoint publishing the current and next signing public keys for stateless JWT verification
  • Expose a /introspect endpoint (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 /token exchanges, under 150ms for /authorize page 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 /token and /authorize; /jwks.json must 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.

OAuth 2.0 authorization server architecture overview showing clients, authorization server endpoints, revocation bus, and resource servers

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.

Key Insight

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.

In-depth analysis Expert insights Full archive access
Unlock Full Article