OAuth 2.0 and OIDC internals
How OAuth 2.0 authorization code flow + PKCE works step by step, what OIDC adds on top, token introspection vs. local JWT verification, the JWKS endpoint, and common implementation mistakes.
The problem
Your team ships a new SPA. Users sign up with a username and password that you store in your own database. A third-party analytics service needs to pull user profile data from your API. The simplest integration? The user gives the analytics service their password. Now that service can call your API as the user.
Three months later, you discover the analytics vendor stored passwords in plaintext and got breached. Every user who shared their credentials is compromised across your system and every other service where they reused that password. You have no way to revoke the analytics service's access without resetting the user's password entirely.
The core issue: there is no separation between "proving who you are" (authentication) and "granting limited access to your stuff" (authorization). The user's password does both, and sharing it means sharing everything. This is the problem OAuth 2.0 solves, and OpenID Connect extends.
What it is
OAuth 2.0 is an authorization framework that lets users grant third-party applications scoped access to their resources without sharing credentials. Instead of handing over a password, the user approves a specific set of permissions, and the authorization server issues a token representing that approval.
Think of it like a hotel key card. The front desk (authorization server) verifies your identity once and gives you a card (token) that opens your room and the gym but not the staff office. You never give the hotel's master key to the room service staff.
OpenID Connect (OIDC) is an identity layer built on top of OAuth 2.0. OAuth answers "is this app allowed to access this resource?" OIDC answers "who is the user?" It adds a standardized ID token (a signed JWT) containing identity claims like email, name, and a stable user identifier.
The access_token is for authorization (calling APIs on behalf of the user). The id_token added by OIDC is for authentication (verifying who the user is). I find that candidates who can articulate this distinction clearly stand out immediately in interviews.
How it works
The authorization code flow with PKCE is the recommended OAuth 2.0 flow for both server-side apps and single-page applications. The older "implicit flow" (returning tokens directly in URL fragments) is deprecated because tokens end up in browser history and server logs.
The flow has three critical security properties. First, the user's credentials never touch the client application. Second, the authorization code is useless without the code_verifier (PKCE). Third, the state parameter prevents CSRF attacks where an attacker tricks the client into using the attacker's authorization code.
Authorization code flow with PKCE
This is the recommended flow for server-side apps and SPAs. The old "implicit flow" (directly returning tokens in URL fragments) is deprecated because tokens leaked via browser history, Referer headers, and server logs.
Step 1: Generate PKCE challenge
The client generates a code_verifier (a cryptographically random 43-128 character string) and computes its SHA-256 hash as the code_challenge. This verifier stays on the client and is never sent to the authorization server until the token exchange.
import secrets, hashlib, base64
code_verifier = secrets.token_urlsafe(64) # 86-char random string
challenge_bytes = hashlib.sha256(code_verifier.encode()).digest()
code_challenge = base64.urlsafe_b64encode(challenge_bytes).rstrip(b'=').decode()
Step 2: Redirect to authorization server
The client redirects the user's browser to the authorization endpoint with the challenge:
GET /authorize?
response_type=code
&client_id=client_123
&redirect_uri=https://app.example.com/callback
&scope=openid profile email
&state=random_csrf_token
&code_challenge=BASE64URL(SHA256(code_verifier))
&code_challenge_method=S256
Step 3: User authenticates and consents
The authorization server shows a login page. The user authenticates and grants consent for the requested scopes. The server redirects back with an authorization code:
https://app.example.com/callback?code=AUTH_CODE&state=random_csrf_token
Step 4: Exchange code for tokens (back-channel)
The client sends the authorization code plus the original code_verifier to the token endpoint. This is a server-to-server call, not a browser redirect:
POST /token
grant_type=authorization_code
code=AUTH_CODE
redirect_uri=https://app.example.com/callback
client_id=client_123
code_verifier=ORIGINAL_VERIFIER
The authorization server computes SHA256(code_verifier) and compares it to the code_challenge from Step 2. If they match, tokens are issued.
Why PKCE is non-negotiable
Without PKCE, an attacker who intercepts the authorization code (via a malicious browser extension, a compromised redirect, or a mobile app deep link hijack) can exchange it for tokens. PKCE binds the code to the client that initiated the flow. Even OAuth confidential clients should use PKCE as defense-in-depth.
State parameter: The client must verify that state in the callback matches what it originally sent. This prevents CSRF attacks where an attacker initiates the flow from their own account and tricks the victim into completing it, linking the attacker's identity to the victim's session.
Token lifecycle: access, refresh, and ID tokens
The token endpoint returns up to three tokens, each with a distinct purpose and lifetime.
Access tokens are short-lived (typically 5 to 60 minutes). They are sent with every API request as a Bearer token. Short lifetimes limit the blast radius if a token is leaked.
Refresh tokens are long-lived (days, weeks, or until explicitly revoked). When the access token expires, the client uses the refresh token to get a new access token without requiring the user to re-authenticate. My recommendation: always use refresh token rotation, where the authorization server issues a new refresh token with each use and invalidates the old one.
ID tokens are used once at login to establish the user's identity. They should not be sent to resource servers or used as API credentials. The ID token tells the client "who logged in." The access token tells the resource server "what this client is allowed to do."
Interview tip: token lifetime tradeoffs
When discussing token lifetimes, state that shorter access token TTLs reduce the window of abuse from stolen tokens but increase the frequency of refresh calls. For high-security systems (banking), access tokens live 5 minutes. For consumer apps, 15-30 minutes is typical. Always mention refresh token rotation as the mitigation for long-lived refresh tokens.
OpenID Connect ID token claims
OIDC adds an id_token to the token response. It is a signed JWT containing standardized identity claims:
{
"iss": "https://accounts.google.com",
"sub": "1234567890",
"aud": "client_123",
"iat": 1700000000,
"exp": 1700003600,
"nonce": "abc123",
"email": "user@example.com",
"name": "Jane Doe",
"email_verified": true,
"at_hash": "HK6E_P6Dh8Y93mRNtsDB1Q"
}
The critical claims to understand:
| Claim | Purpose | Validation rule |
|---|---|---|
iss | Issuer identifier | Must match the expected authorization server URL exactly |
sub | Stable unique user ID | Use as FK to your users table (not email) |
aud | Intended audience | Must contain your client_id. Reject if it doesn't |
exp | Expiration time | Reject if current time > exp |
nonce | Replay prevention | Must match the nonce your client sent in /authorize |
at_hash | Access token binding | SHA256 left-half of the access_token, Base64URL encoded |
The sub claim is the stable, unique user identifier within a given issuer. Use sub (scoped to iss) as the foreign key to your users table. Never use email as the primary identifier because emails can change, and different providers may assign the same email to different users.
The nonce claim prevents token replay attacks. The client generates a random nonce, sends it in the authorization request, and verifies it appears unchanged in the returned ID token. Without nonce validation, an attacker could replay a previously issued ID token.
Token verification and introspection
There are two ways to validate an access token, and the choice has significant architectural implications.
Local JWT verification (for JWT-format access tokens):
- Fetch the authorization server's public keys from its JWKS endpoint (cache aggressively)
- Extract the
kidheader from the JWT and find the matching public key - Verify the token's signature using RSA or EC
- Validate
iss,aud,exp,iat, and any required custom claims
This is stateless and fast, requiring no network call per request. But it cannot detect tokens that have been revoked before expiry. If a user logs out or an admin revokes access, locally verified JWTs remain valid until they expire.
Token introspection (RFC 7662): Call the authorization server's introspection endpoint with the token:
POST /introspect
token=ACCESS_TOKEN
token_type_hint=access_token
Response:
{ "active": true, "scope": "read write", "sub": "user:123", "exp": 1700003600 }
This checks revocation status at the source of truth. The tradeoff is a network round-trip per request. I've found that caching introspection results for 30-60 seconds is the right balance for most systems: you get near-real-time revocation without hammering the authorization server.
JWKS endpoint and key rotation
The JWKS (JSON Web Key Set) endpoint exposes the authorization server's public keys:
GET https://accounts.google.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "key-id-1", // key identifier
"n": "...", // RSA modulus
"e": "AQAB" // RSA exponent
}
]
}
JWTs include a kid (key ID) header matching one of the keys in the JWKS. Cache the JWKS response (it changes infrequently) but refresh when a kid is not found, because the authorization server may have rotated keys.
The JWKS (JSON Web Key Set) endpoint exposes the authorization server's public keys for signature verification:
GET https://accounts.google.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "key-2024-01",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "key-2024-02",
"use": "sig",
"alg": "RS256",
"n": "...",
"e": "AQAB"
}
]
}
The JWT header contains a kid (key ID) referencing one of the keys from the JWKS. Cache the JWKS response (it changes infrequently, typically on a 24-hour TTL) but implement a fallback: if a kid is not found in the cached set, re-fetch the JWKS once. The authorization server may have rotated keys.
Key rotation is critical for security. Best practice is to publish the new key in JWKS before signing tokens with it (a "key rollover" period), so resource servers have time to cache the new key. The JWKS should contain at least two keys during rotation: the current signing key and the previous one.
function verify_jwt(token):
header = decode_header(token)
kid = header["kid"]
key = jwks_cache.get(kid)
if key is None:
jwks_cache.refresh() // re-fetch from /.well-known/jwks.json
key = jwks_cache.get(kid)
if key is None:
return REJECT // unknown key, reject token
if not verify_signature(token, key):
return REJECT
claims = decode_payload(token)
if claims.iss != EXPECTED_ISSUER: return REJECT
if claims.aud != MY_CLIENT_ID: return REJECT
if claims.exp < current_time(): return REJECT
return ACCEPT(claims)
Production usage
| System | OAuth/OIDC usage | Notable behavior |
|---|---|---|
| Google Identity Platform | Full OIDC provider with authorization code + PKCE | Issues JWT access tokens; JWKS rotates keys every ~6 hours; supports incremental consent (request scopes progressively) |
| Auth0 / Okta | Identity-as-a-Service, OIDC-compliant | Supports refresh token rotation by default; opaque access tokens for first-party APIs, JWT for third-party |
| GitHub OAuth | OAuth 2.0 (not full OIDC) | Issues opaque access tokens, not JWTs; requires introspection; scopes are coarse-grained (repo, user, admin) |
| AWS Cognito | OIDC provider for app authentication | User pools issue JWTs; supports custom claims via pre-token-generation Lambda triggers; refresh tokens default to 30 days |
| Kubernetes (OIDC) | Uses OIDC for cluster authentication | API server validates ID tokens from external providers; no token introspection, local JWT verification only |
Limitations and when NOT to use it
- Machine-to-machine communication does not need the authorization code flow. Use the client credentials grant instead. There is no user to redirect, no browser, and no consent screen. Using auth code flow for service-to-service calls adds unnecessary complexity.
- OAuth does not define how to manage permissions within your application. OAuth scopes control what an application can do, not what a specific user can do. You still need application-level authorization (RBAC, ABAC) on top of OAuth tokens.
- Local JWT verification cannot enforce real-time revocation. If you need instant logout (compliance, security incidents), you must use token introspection or maintain a revocation list, both of which add latency or state.
- Token storage in browsers has no perfect solution. LocalStorage is vulnerable to XSS. Cookies need CSRF protection. In-memory storage is lost on page refresh. Every option has tradeoffs, and the "correct" answer depends on your threat model.
- Short-lived tokens with refresh flows add complexity to every client. Every HTTP client must handle 401 responses, refresh the token, and retry the original request. This retry logic is a common source of bugs, especially around race conditions when multiple requests trigger concurrent refreshes.
- OIDC discovery adds a startup dependency. Clients that fetch
/.well-known/openid-configurationat boot time fail if the authorization server is down. Cache discovery documents and handle startup without them.
Interview cheat sheet
- When asked about authentication vs. authorization, state that OAuth 2.0 is authorization (granting scoped access via tokens) and OIDC adds authentication (identity assertion via ID tokens). Do not conflate the two.
- When discussing OAuth flows, recommend the authorization code flow with PKCE for all clients, including SPAs and mobile apps. Mention that the implicit flow is deprecated because tokens were exposed in URL fragments.
- When a design calls for "login with Google/GitHub," say OIDC. The authorization server handles authentication and returns an ID token with the user's identity. Your app never sees or stores the user's password.
- When asked how to verify tokens, describe two paths: local JWT verification (stateless, fast, no revocation) and token introspection (stateful, revocation-aware, network cost). State the tradeoff explicitly.
- When discussing token storage in browsers, say: access tokens in memory, refresh tokens in httpOnly secure cookies. Never localStorage for tokens because of XSS exposure.
- When asked about refresh tokens, mention rotation: each use invalidates the old refresh token and issues a new one. If a stolen refresh token is used after the legitimate client has already rotated it, the authorization server detects the reuse and revokes the entire grant.
- When a system needs instant revocation (admin force-logout, compromised account), state that short-lived JWTs alone are insufficient. You need either introspection, a token blocklist at the gateway, or very short TTLs (under 5 minutes) with aggressive refresh.
- When asked about key management, mention JWKS endpoints,
kidmatching, and key rotation with overlap periods. The most common production incident I've seen is a resource server caching an old JWKS and rejecting all tokens after a key rotation.
Quick recap
- OAuth 2.0 is authorization (granting scoped access to resources via tokens). OIDC adds authentication (verifying user identity via a signed ID token). The access token calls APIs; the ID token identifies the user.
- The authorization code flow with PKCE is the only recommended flow for all client types. The client generates a secret
code_verifier, sends its hash as thecode_challenge, and proves possession during the token exchange, preventing stolen codes from being used. - Three token types serve distinct purposes: access tokens (short-lived, sent to APIs), refresh tokens (long-lived, used to get new access tokens), and ID tokens (one-time identity assertion at login).
- The ID token's
subclaim (scoped toiss) is the stable unique user identifier. Use it as your primary key, not email, because email is mutable and non-unique across providers. - Local JWT verification is stateless and fast but cannot detect revocation. Token introspection detects revocation but adds a network call per request. Cache introspection results for 30-60 seconds to balance freshness and cost.
- Cache JWKS aggressively but implement "re-fetch on unknown kid" logic. The most common production incident is stale JWKS caches rejecting valid tokens after key rotation.
Related concepts
- JWT internals - OAuth and OIDC rely on JWTs for ID tokens and often for access tokens. Understanding JWT structure, signing algorithms, and header/payload encoding is essential for debugging token validation failures.
- Security - OAuth/OIDC is one layer of a defense-in-depth security architecture. Knowing how it fits with TLS, CORS, CSP, and other web security mechanisms prevents gaps.
- API gateway - API gateways commonly handle token validation (JWKS verification, introspection caching) as a cross-cutting concern, offloading it from individual microservices.
- Databases - Token storage (refresh tokens, authorization grants, consent records) and user identity mapping (
iss+subas composite key) require careful schema design.