OAuth 2.0 & OpenID Connect — Refresh Token Leak in SPA Logs
A leaked refresh token from production console logs let attackers mint tokens after user logout.
- OAuth 2.0 lets apps act on your behalf without sharing your password
- OpenID Connect (OIDC) adds authentication — it tells the app who you are
- Authorization Code flow with PKCE is the standard for mobile and SPAs
- Token validation (issuer, audience, expiry, signature) prevents 90% of production attacks
- Biggest mistake: storing access tokens in localStorage — one XSS and they're gone
- DPoP binds tokens to the client, preventing replay across endpoints
Every time a user clicks 'Sign in with Google' or grants a fitness app access to their calendar, OAuth 2.0 is running the show. It's the protocol that powers delegated authorization for billions of API calls every day — and it's also one of the most misunderstood protocols in production systems. Teams routinely ship broken or insecure OAuth implementations because they treat it like a black box, copy-paste an authorization URL, and call it done. That's a recipe for token leakage, privilege escalation, and account takeover at scale.
If you're building apps that integrate with third-party services or implementing your own auth server, understanding OAuth internals isn't optional. It's what separates a secure integration from a breach waiting to happen.
We'll skip the textbook definitions and get straight to what matters: the flows that work, the validation steps that matter, and the pitfalls that take down production systems. You'll see real code, real attacks, and the exact rules to prevent them. Here's the blunt truth: most OAuth failures are caused by things you can fix in five minutes — wrong endpoints, missing state, or skipping signature checks. Let's fix that now.
What is OAuth 2.0 and OpenID Connect?
OAuth 2.0 solves a fundamental problem: granting third-party applications limited access to a user's resources without exposing the user's credentials. It's the protocol behind 'Sign in with Google' and delegated API access. At its core, OAuth defines four roles: resource owner (user), client (app), authorization server, and resource server. The interactions between these roles are governed by grant types — flows that determine how tokens are obtained.
OAuth 2.0 and OpenID Connect is a core concept in System Design. Rather than starting with a dry definition, let's see it in action and understand why it exists. OpenID Connect (OIDC) builds on OAuth by adding an identity layer: a signed ID token that contains the user's identity. Without OIDC, you know what the app can do, but not who the user is. That's why every 'Login with Google' button triggers both OAuth and OIDC.
Key roles in detail: - Resource Owner: You — the person who owns the data (e.g., photos in Google Drive) - Client: The app requesting access (e.g., a photo printing service) - Authorization Server: The identity provider that issues tokens (e.g., Google's auth server) - Resource Server: The API that holds the actual data (e.g., Google Drive API)
One thing every senior engineer learns quickly: the authorization server and resource server are often separate services. You can't assume the same token works for both. Access tokens are for the resource server, ID tokens are for the client. Mixing them up is a classic production bug.
Common misconception: OAuth does not authenticate the user. It authorizes a client. That's why OIDC exists — to add authentication. Many teams mistakenly treat the access token as proof of identity and skip the ID token. That works until you need to invalidate a user's login without affecting ongoing background tasks. OIDC gives you the concept of a 'session' through the ID token lifecycle.
The Authorization Code Flow — Step by Step
This is the most common OAuth 2.0 flow for server-side web apps. The user authorizes via the authorization server, receives a code, and the backend exchanges it for tokens. No tokens ever touch the browser — that's the whole point.
The flow: User clicks 'Login' → redirected to auth server → user authenticates and approves → auth server redirects back with a code → backend sends code + client credentials to token endpoint → receives access token (and optionally an ID token and refresh token).
PKCE (Proof Key for Code Exchange) adds a secret challenge to prevent code interception. No client secret needed — that's why it's mandatory for mobile apps and SPAs.
PKCE works by having the client create a cryptographically random string called the code_verifier, then sending its SHA-256 hash (code_challenge) during the authorization request. When exchanging the code for tokens, the client sends the original code_verifier, and the authorization server verifies that the hash matches. This prevents an attacker who intercepts the authorization code from exchanging it, because they don't have the code_verifier.
Additional security measures: - Always include a state parameter to prevent CSRF on the callback. - For OIDC, use the nonce parameter to bind the ID token to the session. - The redirect_uri must be an exact match — no wildcards. - The state value should be a cryptographically random string, stored server-side or in a session cookie for verification upon return.
A real-world nuance: In production, you'll see providers like Auth0 or Okta enforce PKCE even for confidential clients. Don't fight it — it's belt-and-suspenders security that costs nothing.
Timing warning: Authorization codes are extremely short-lived (often 1–5 minutes). If your token exchange fails because of a network timeout, you may need to restart the flow. Always have a retry strategy that redirects the user back to the authorize endpoint rather than retrying the same code.
OpenID Connect — Adding Identity to OAuth
OAuth 2.0 handles authorization — what you can do. OpenID Connect (OIDC) handles authentication — who you are. OIDC extends OAuth with an ID token (a JWT) that contains the user's identity claims.
The ID token is signed by the authorization server using a private key. The client verifies the signature using the public key from the JWKS endpoint. The standard claims include sub (subject — unique user ID), iss (issuer), aud (audience — must match your client ID), exp, iat.
OIDC also defines the UserInfo endpoint — a protected API that returns the user's profile. You use the access token to call it. The UserInfo endpoint is useful for getting additional claims not included in the ID token, like profile picture or phone number. However, relying on UserInfo adds network latency; for performance-critical paths, include needed claims in the ID token.
OIDC scopes and claims: - openid scope is required to trigger OIDC - profile scope gives access to name, family_name, etc. - email scope returns email and email_verified - You can also request custom claims via the claims parameter (if the provider supports it) - The sub claim is a unique identifier for the user — never change it
Production gotcha: Some providers include the at_hash claim in the ID token. Always verify it if present — it ensures the ID token is bound to the access token. If you skip this, an attacker could swap tokens from another session.
Additional gotcha: The nonce claim in the ID token prevents replay of the ID token itself. If your provider supports it, use it. Without nonce, an attacker who intercepts an ID token could present it to your client and log in as that user, even if the session expired. The nonce ties the token to a specific authentication request.
Token Validation — Don't Trust, Verify
Tokens received from the authorization server must be validated before use. For JWTs, the validation steps are: 1. Check iss (issuer) matches your authorization server's identifier. 2. Check aud (audience) matches your client ID (for ID tokens) or the resource server ID (for access tokens). 3. Check exp is in the future. 4. Verify the signature using the public key from the JWKS endpoint.
For opaque tokens (like reference tokens), you must call the introspection endpoint. This adds latency but allows revocation.
Don't forget to check token_type — a misconfigured client might accept a 'Bearer' token that's actually a 'MAC' or 'DPoP' token.
Also important: check the nbf (not before) claim to reject tokens used before their valid time. And always allow for clock skew — typically 60 seconds — between your server and the authorization server.
Algorithm whitelisting critical: If you accept alg: none or alg: HS256 when you expect RS256, an attacker can forge tokens. Always whitelist the allowed algorithms in your JWT library.
JWKS caching: Fetch the JWKS endpoint and cache it with a reasonable TTL (e.g., 1 hour). But be ready to refetch on failure in case the provider rotated keys. Use the kid in the token header to select the correct key. Set up a background refresh job to keep the cache warm, or use a library that does it automatically. Otherwise, key rotation will break your token validation until the cache expires.
A real attack we've seen: A team validated signatures but forgot to check aud. An access token from Service A was used to call Service B because both services used the same JWKS endpoint. The fix: resource servers must check aud equals their own identifier. This is one of the most common OAuth misconfigurations we encounter.
Clock skew handling: Set your token validator to allow a tolerance of up to 2 minutes (120 seconds) to account for network delays and unsynchronized clocks. Too tight and you'll reject valid tokens; too loose and you open a window for replay.
Common Production Pitfalls and How to Avoid Them
Even when you follow the flows correctly, production OAuth implementations fail silently. Here are the traps senior engineers see repeatedly:
- Refresh token rotation without family tracking — if the same refresh token is used twice (e.g., two parallel requests), the second one invalidates the first. Your user gets logged out randomly. Solution: implement rotation families (the server keeps the previous refresh token valid for a short window).
- Incorrect redirect URI validation — the authorization server must compare redirect URIs as exact strings (including trailing slash, query params). Some servers allow partial matching — that's a security hole. Always register the exact URI.
- Storing tokens in localStorage — any XSS vulnerability leaks the token. Use secure, httpOnly cookies for web apps, or store tokens only in memory for SPAs and rely on short-lived sessions.
- Ignoring the
nonesignature algorithm — an attacker can modify the JWT header toalg: noneand your server might accept it if validation is lenient. Always enforce a whitelist of accepted algorithms. - Mixing up authorization server endpoints — using the authorize endpoint for token exchange, or vice versa. Each endpoint has a strict purpose. Double-check your URLs.
- Failing to handle clock skew — if your server's clock is more than 60 seconds off from the authorization server, valid tokens will be rejected. Use
nbfandexpclaims with a configured clock skew tolerance. - Missing
stateparameter — withoutstate, the authorization callback is vulnerable to CSRF. An attacker can trick the user into exchanging an attacker's authorization code. Always generate a uniquestatevalue and verify it on return. - Not rotating signing keys — if the authorization server's private key is compromised, all tokens are forgeable. Implement key rotation and ensure your JWKS endpoint is updated.
- Using the same client secret across environments — development, staging, and production should have different client credentials. A leak in a lower environment should not compromise production.
We've seen each of these cause real production outages. The first one (refresh token rotation without family) is especially insidious because it looks like a random logout to users. The second one (open redirect) is a compliance nightmare.
Token Binding and DPoP — Protecting Against Token Replay
Standard OAuth 2.0 bearer tokens are like cash: whoever possesses them can use them. If a token is leaked, an attacker can replay it from any device. DPoP (Demonstration of Proof of Possession) binds a token to a specific client by requiring the client to prove possession of a private key.
The client generates a key pair and registers the public key with the authorization server during the token request. Every API call includes a DPoP proof JWT signed with the private key. The resource server verifies this proof before accepting the access token.
Benefits: - Token theft becomes useless: the token cannot be used without the private key. - Protects against token replay across endpoints (the proof includes the HTTP method and URI). - No need for client secrets — key pairs are ephemeral.
Trade-offs: - Requires cryptographic operations on every request (adds ~2ms per call). - More complex key management on the client side. - Not all providers support DPoP yet; check your authorization server. - DPoP tokens are not compatible with introspection endpoints that expect opaque bearer tokens.
Implementation steps: 1. Generate an EC or RSA key pair on the client. 2. Include the public key (thumbprint) as a cnf claim in the client's JWK. 3. When requesting a token, include the DPoP header with a signed proof. 4. For every protected request, include a fresh DPoP proof in the DPoP header.
When should you adopt DPoP? If you're handling financial transactions, healthcare data, or any scenario where token theft would be catastrophic. For most apps, short-lived tokens + refresh token rotation + strict CSP is sufficient, but DPoP is the next level of defense.
One more nuance: DPoP affects the token endpoint as well — the authorization server must verify the proof during token issuance. Some providers require a DPoP-bound token even for the initial authorization request. Check your provider's documentation.
| Flow | Use Case | Token in Browser | Client Secret Needed | Refresh Token Supported |
|---|---|---|---|---|
| Authorization Code + PKCE | SPAs, Mobile Apps | No (code only) | No | Yes |
| Authorization Code (without PKCE) | Server-side web apps | No (code only) | Yes | Yes |
| Client Credentials | Machine-to-machine | N/A | Yes | No |
| Resource Owner Password | Legacy / trusted apps (deprecated) | N/A | Yes | Yes |
| Implicit (deprecated) | Legacy SPAs | Token in URL fragment | No | No |
Key Takeaways
- You now understand what OAuth 2.0 and OpenID Connect is and why it exists
- You've seen it working in a real runnable example
- Practice daily — the forge only works when it's hot 🔥
- Authorization Code flow with PKCE is the standard for all public clients - never use Implicit
- Always validate tokens: issuer, audience, expiry, signature, and algorithm whitelist
- Store tokens securely: memory for SPAs, httpOnly cookies for web apps, never localStorage
- Refresh token rotation is non-negotiable for security — use rotation families with a grace window
- DPoP binding prevents token replay across clients — adopt it for high-security environments
- Always include and validate the state parameter to prevent CSRF
- Separate client credentials per environment — dev leaks should not affect prod
Common Mistakes to Avoid
- Storing tokens in localStorage
Symptom: After an XSS attack, attacker exfiltrates the access token and makes API calls on behalf of the user.
Fix: Store tokens in memory only (SPA) or use secure httpOnly cookies (web app). For mobile, use secure storage like Keychain/Keystore. - Not using PKCE for public clients
Symptom: An attacker with access to the browser can intercept the authorization code and exchange it for tokens because no code verifier is required.
Fix: Always use PKCE for any client that cannot keep a secret. Generate a code_verifier and code_challenge; send the challenge in the authorize request and the verifier in the token request. - Accepting 'alg: none' in JWT validation
Symptom: An attacker crafts a token with header `{"alg":"none"}` and arbitrary payload. If the server doesn't check the algorithm, it accepts the token as valid.
Fix: Whitelist allowed algorithms in your JWT validation library. Reject tokens withalg: noneoralg: HS256when you expectRS256. - Using the implicit flow for modern apps
Symptom: Access token is exposed in the URL fragment. It can be leaked through browser history, referer headers, or server logs.
Fix: Migrate to Authorization Code flow with PKCE. The implicit flow is deprecated and insecure for modern SPAs. - Ignoring token expiry and not handling refresh failures
Symptom: Users get logged out randomly. Refresh token rotation fails silently, and the user sees an error instead of re-authenticating gracefully.
Fix: Implement proper refresh token rotation with a family model (short grace period for old tokens). Handle 401 errors from refresh by redirecting to the authorization endpoint. - Failing to rotate refresh tokens
Symptom: A compromised refresh token can be used indefinitely to obtain new access tokens.
Fix: Enable refresh token rotation: each token exchange returns a new refresh token and invalidates the previous one. Use a grace window for concurrent requests. - Missing state parameter in authorization request
Symptom: The authorization callback is vulnerable to CSRF. An attacker can trick a user into exchanging an authorization code that the attacker obtained, leading to token theft.
Fix: Always include a cryptographically randomstateparameter in the authorization request and validate it when the redirect returns. Reject mismatches immediately. - Not validating the token_type claim
Symptom: The resource server accepts a token with `token_type: Bearer` but the client sent a DPoP token — or vice versa. The token passes signature validation but the expected binding is wrong, leading to potential replay or bypass.
Fix: Check thetoken_typeclaim in the token response and enforce expected type (Bearer or DPoP) in your resource server. Reject if type doesn't match the negotiated flow. - Using same client secret across environments
Symptom: If development credentials are leaked, the attacker can request tokens for production because the same client_id and secret work.
Fix: Register separate clients for each environment. Use a different client_id and secret for dev, staging, and prod.
Interview Questions on This Topic
- QExplain the difference between OAuth 2.0 and OpenID Connect.JuniorReveal
- QWhat is PKCE and why is it needed?Mid-levelReveal
- QHow do you validate an ID token in production?Mid-levelReveal
- QWhat is the difference between access token and refresh token?JuniorReveal
- QDescribe a real-world OAuth security incident you've seen or would prevent.SeniorReveal
- QHow would you design a token revocation mechanism for a distributed system?SeniorReveal
- QWhat is the purpose of the
stateparameter in OAuth?Mid-levelReveal - QExplain how DPoP prevents token replay attacks.SeniorReveal
- QWhat's the difference between an ID token and an access token at the JWT level?SeniorReveal
- QHow would you implement OAuth for a microservices architecture with internal service-to-service calls?SeniorReveal
Frequently Asked Questions
What is OAuth 2.0 and OpenID Connect in simple terms?
OAuth 2.0 and OpenID Connect is a fundamental concept in System Design. Think of it as a tool — once you understand its purpose, you'll reach for it constantly.
Is OAuth 2.0 an authentication or authorization protocol?
OAuth 2.0 is strictly an authorization protocol — it grants access to resources. It does not authenticate the user. OpenID Connect adds authentication on top by introducing the ID token, which contains identity claims.
Can I use OAuth 2.0 without OpenID Connect?
Yes, if you only need authorization (e.g., allowing an app to post on your behalf). But if you need to know the user's identity (e.g., login), you need OpenID Connect. Many providers offer both: the /oauth/authorize endpoint with scope=openid triggers OIDC.
What is the difference between scope and claims in OIDC?
Scopes request specific sets of claims. For example, scope=openid profile email requests the sub, name, and email claims in the ID token. Claims are the actual key-value pairs returned. You can also request individual claims via the claims parameter (if supported).
How do I handle token revocation on user logout?
For access tokens, you can let them expire (if short-lived) or use a blacklist. For refresh tokens, call the authorization server's revocation endpoint (/oauth/revoke) with the token. The server will invalidate it. Some implementations also rotate refresh tokens on logout.
What is the recommended lifetime for access tokens?
Access tokens should be short-lived — typically 15 to 60 minutes. Shorter lifetimes reduce the blast radius of a leak but increase the frequency of refresh token usage. Use 15 minutes for high-security apps, 60 minutes for lower-risk scenarios. Refresh tokens can last days or months, but should be rotated on each use.
What is DPoP and when should I use it?
DPoP (Demonstration of Proof of Possession) is an extension that binds an access token to a specific client by requiring the client to prove possession of a private key. Use DPoP in high-security environments where token theft via XSS or network interception is a realistic threat. It adds about 2ms per request for cryptographic overhead.
What is the difference between hash (at_hash) in the ID token and why does it matter?
The at_hash claim is a hash of the access token. It binds the ID token to the access token. If you skip verifying at_hash, an attacker could swap the access token from one session with another, potentially obtaining a token with different scopes or permissions. Always verify at_hash if present.
How does refresh token rotation work and why is it important?
Refresh token rotation means every time the client exchanges a refresh token for a new access token, the authorization server also returns a new refresh token and invalidates the old one. This limits the window of exposure: if a refresh token is stolen, it can only be used once before being rotated. However, to handle concurrent requests, implement a grace window where the previous refresh token remains valid for a few seconds.
That's Security. Mark it forged?
10 min read · try the examples if you haven't