Low Level Design: OAuth 2.0 and JWT Authentication

OAuth 2.0 and JWT are the foundation of authentication and authorization in modern web services. Despite being widely used, the details of how they work — and critically, how they can fail — are frequently misunderstood. This post covers the full protocol flow, token internals, and security trade-offs you need to design and evaluate auth systems.

OAuth 2.0 Roles

OAuth 2.0 defines four roles. The resource owner is the user who owns the data and can grant access. The client is the application requesting access on the user’s behalf (a web app, mobile app, or server). The authorization server authenticates the user, obtains consent, and issues tokens (e.g., Google’s OAuth server, Auth0, Okta). The resource server hosts the protected API and validates tokens on each request.

The authorization server and resource server are logically separate and may be operated by different organizations. A single authorization server can issue tokens for multiple resource servers. This separation is what makes OAuth a delegation protocol — the client never sees the user’s credentials.

Authorization Code Flow

The authorization code flow is the correct flow for web applications with a server-side component. The sequence: (1) The client redirects the user’s browser to the authorization server with response_type=code, client_id, redirect_uri, scope, and a random state parameter. (2) The authorization server authenticates the user and displays a consent screen. (3) On consent, the server redirects the browser back to redirect_uri with an authorization code (short-lived, single-use, ~60 seconds). (4) The client’s server makes a back-channel POST to the /token endpoint with the code, client_id, client_secret, and redirect_uri. (5) The authorization server returns an access token and optionally a refresh token.

The critical design insight is that the access token never appears in the browser URL or history. It is exchanged back-channel (server-to-server) using the client secret as proof of identity. The authorization code in the URL is useless without the client secret. The state parameter is the CSRF protection — the client verifies it matches what it sent before trusting the callback.

PKCE: Proof Key for Code Exchange

PKCE (RFC 7636, pronounced "pixie") solves the authorization code interception attack for public clients: mobile apps and SPAs that cannot securely store a client secret. A malicious app on the same device could register the same redirect URI and steal the authorization code from the OS intent system.

PKCE works by having the client generate a random code verifier (43–128 random characters) and compute a code challenge = BASE64URL(SHA256(code_verifier)). The code challenge is sent with the authorization request. When exchanging the code for tokens, the client sends the original code_verifier. The server hashes it and checks it matches the stored challenge. An attacker who intercepts the code cannot use it without the code_verifier, which was never transmitted. PKCE is now recommended for all client types, including confidential clients.

Client Credentials Flow

The client credentials flow is for machine-to-machine (M2M) communication where there is no user involved. The client authenticates directly to the /token endpoint with its client_id and client_secret (or a signed JWT assertion) and receives an access token scoped to the service’s own permissions. This is the correct flow for microservice-to-microservice calls, background jobs, and CI/CD pipelines accessing APIs.

There is no refresh token in the client credentials flow — when the access token expires, the client simply requests a new one. Since the client controls the credentials, there is no user session to maintain.

Implicit Flow (Deprecated)

The implicit flow was designed for browser-based SPAs before PKCE existed. It skips the authorization code step and returns the access token directly in the URL fragment (#access_token=...). This was considered acceptable because fragments are not sent to servers in HTTP Referer headers.

It is now deprecated (OAuth 2.1 removes it). The problems: the token appears in browser history and can be leaked via Referer headers on redirects; there is no client authentication and no way to issue refresh tokens safely; and the token is exposed to any JavaScript on the page, including third-party scripts. SPAs should use the authorization code flow with PKCE instead.

JWT Structure

A JSON Web Token (JWT) is three base64url-encoded JSON objects joined by dots: header.payload.signature. The header specifies the token type (JWT) and signature algorithm (alg: RS256, HS256, ES256, etc.). The payload contains claims — assertions about the subject. The signature is computed over base64url(header) + "." + base64url(payload) using the key specified in the header.

Standard registered claims include: iss (issuer), sub (subject — typically a user ID), aud (audience — intended recipient), exp (expiration time, Unix timestamp), iat (issued-at time), and jti (JWT ID for uniqueness). Custom claims can carry application-specific data such as roles, permissions, or tenant IDs. The payload is not encrypted by default — anyone can decode it; only the signature proves authenticity. JWE (JSON Web Encryption) is a separate standard for encrypted tokens.

JWT Signature Algorithms

Three algorithm families are commonly used. RS256 (RSA + SHA-256): the authorization server signs with its RSA private key; resource servers verify with the corresponding public key, which is typically published at a JWKS (JSON Web Key Set) endpoint. Enables asymmetric trust — resource servers never see the private key. HS256 (HMAC-SHA-256): uses a shared secret between authorization server and resource server. Simple but requires secure secret distribution; any party with the secret can both verify and forge tokens. ES256 (ECDSA with P-256): like RS256 but using elliptic curve cryptography — smaller signatures and faster verification than RSA at equivalent security levels.

A critical vulnerability to defend against: the alg: none attack. Early JWT libraries accepted tokens with algorithm set to none and no signature. Always explicitly specify the expected algorithm when verifying — never trust the algorithm from the token header alone.

Stateless JWT Validation

The primary advantage of JWTs is stateless validation. A resource server can verify a JWT by: (1) decoding the header to get the key ID (kid), (2) fetching the corresponding public key from the authorization server’s JWKS endpoint (cached), (3) verifying the signature cryptographically, (4) checking that exp is in the future, iss matches the expected issuer, and aud includes the service’s identifier. No database lookup, no network call to the authorization server per request. This enables horizontal scaling with no shared session state.

Resource servers should cache the JWKS response (respecting Cache-Control) but implement cache-busting on unknown kid values to support key rotation without downtime.

JWT Revocation Problem and Refresh Token Rotation

JWTs are self-contained and stateless, which creates a fundamental problem: you cannot invalidate a valid token before it expires. If a user’s account is compromised and their access token is revoked in the database, the JWT is still cryptographically valid until exp. The resource server, doing stateless validation, has no way to know it was revoked.

The standard mitigations: keep access token lifetimes short (5–15 minutes) to limit the revocation window. Issue long-lived refresh tokens (days or weeks) that are stored server-side and can be revoked. When the access token expires, the client uses the refresh token to get a new one. For strict revocation requirements, maintain a token blacklist in Redis (keyed by jti) and check it on every request — at the cost of a Redis round-trip, eliminating full statefulness.

Refresh token rotation improves security: each use of a refresh token invalidates the old one and issues a new one. If a refresh token is stolen and used by an attacker, the next legitimate use by the real client will fail (the old token is gone), alerting the system to a potential compromise. Many authorization servers implement reuse detection: if a rotated (already-used) refresh token is presented, all tokens for that session are immediately revoked.

OpenID Connect

OpenID Connect (OIDC) is a thin identity layer built on top of OAuth 2.0. OAuth 2.0 handles authorization ("can this app access this resource?") but says nothing about who the user is. OIDC adds authentication ("who is this user?").

OIDC adds three things to OAuth 2.0: an id_token (a JWT returned alongside the access token that contains identity claims about the authenticated user — sub, email, name, etc.), a /userinfo endpoint (a protected resource the client can call with the access token to get additional user attributes), and standard scopes: openid (required to trigger OIDC, returns sub), profile (name, picture, locale), and email. The id_token is for the client to consume; the access token is for calling APIs. Never use the access token as proof of identity — use the id_token or /userinfo instead.

Scroll to Top