Advanced 10 min · March 06, 2026

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.

N
Naren · Founder
Plain-English first. Then code. Then the interview question.
About
Quick Answer
  • 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:

  1. 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).
  2. 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.
  3. 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.
  4. Ignoring the none signature algorithm — an attacker can modify the JWT header to alg: none and your server might accept it if validation is lenient. Always enforce a whitelist of accepted algorithms.
  5. 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.
  6. 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 nbf and exp claims with a configured clock skew tolerance.
  7. Missing state parameter — without state, the authorization callback is vulnerable to CSRF. An attacker can trick the user into exchanging an attacker's authorization code. Always generate a unique state value and verify it on return.
  8. 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.
  9. 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.

OAuth 2.0 Flows Comparison
FlowUse CaseToken in BrowserClient Secret NeededRefresh Token Supported
Authorization Code + PKCESPAs, Mobile AppsNo (code only)NoYes
Authorization Code (without PKCE)Server-side web appsNo (code only)YesYes
Client CredentialsMachine-to-machineN/AYesNo
Resource Owner PasswordLegacy / trusted apps (deprecated)N/AYesYes
Implicit (deprecated)Legacy SPAsToken in URL fragmentNoNo

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 with alg: none or alg: HS256 when you expect RS256.
  • 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 random state parameter 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 the token_type claim 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
    OAuth 2.0 is an authorization framework — it allows a client to access resources on behalf of a user. OpenID Connect (OIDC) is an authentication layer built on top of OAuth 2.0. OIDC adds an ID token (a JWT) that contains identity claims about the user. In practice, OAuth 2.0 answers 'what can the app do?' while OIDC answers 'who is the user?'
  • QWhat is PKCE and why is it needed?Mid-levelReveal
    PKCE (Proof Key for Code Exchange) is an extension to the Authorization Code flow that prevents authorization code interception attacks. The client generates a cryptographically random code_verifier and sends its SHA-256 hash (code_challenge) during the authorization request. When exchanging the code for tokens, the client sends the original code_verifier. The server verifies that the hash matches. This way, even if an attacker intercepts the authorization code, they cannot exchange it without the code_verifier. PKCE is mandatory for public clients (SPAs, mobile) that cannot store a client secret securely.
  • QHow do you validate an ID token in production?Mid-levelReveal
    Step-by-step: 1. Verify the token is a JWT with a supported algorithm (e.g., RS256). 2. Check the iss (issuer) matches the authorization server's expected URI. 3. Check the aud (audience) contains your client ID. 4. Validate the exp (expiry) and iat (issued at) are reasonable. 5. Verify the signature using the public key from the authorization server's JWKS endpoint. The kid header indicates which key to use. 6. (Optional) Call the UserInfo endpoint with the access token to get additional claims. Use a well-tested JWT library for signature verification; don't implement it from scratch.
  • QWhat is the difference between access token and refresh token?JuniorReveal
    An access token is used to authenticate API calls. It is short-lived (often 15-60 minutes) to limit damage if leaked. A refresh token is a long-lived credential used to obtain new access tokens without re-authenticating the user. Refresh tokens should be stored securely and sent only to the token endpoint. With refresh token rotation, each refresh returns a new refresh token and invalidates the old one, preventing replay attacks.
  • QDescribe a real-world OAuth security incident you've seen or would prevent.SeniorReveal
    In one incident, a company's SPA used the Implicit flow and stored the access token in localStorage. An XSS vulnerability in a third-party widget allowed an attacker to exfiltrate the token and make API calls. The fix involved migrating to Authorization Code with PKCE, storing the token in memory (not localStorage), and implementing Content Security Policy headers to block inline scripts. Also, they introduced short token lifetimes and refresh token rotation to limit the blast radius.
  • QHow would you design a token revocation mechanism for a distributed system?SeniorReveal
    For a distributed system, you need a central revocation store that all resource servers can access. Options: - Blacklist: maintain a distributed cache (e.g., Redis) of revoked token IDs (JTI). Each resource server checks the cache on every request. The authorization server adds the token to the blacklist on logout or revocation. This adds latency but ensures immediate revocation. - Opaque tokens with introspection: the resource server calls the authorization server's introspection endpoint on each call. The AS checks a central DB for revocation status. Higher latency but simpler. - Short-lived tokens + refresh token rotation: tokens expire quickly (5-15 min), and revocation is enforced at the token endpoint. The user cannot get new tokens after revocation because the refresh token is invalidated. No shared state needed, but there's a small window where a token could still be used. Recommendation: use short-lived access tokens with refresh token rotation for most scenarios. If you need instant revocation, combine with a blacklist cache for the access token's lifetime.
  • QWhat is the purpose of the state parameter in OAuth?Mid-levelReveal
    The state parameter is used to prevent Cross-Site Request Forgery (CSRF) on the authorization callback. The client generates a unique, cryptographically random value and includes it in the authorization request. When the user is redirected back, the client checks that the state value matches what was sent. If it doesn't, the request is rejected. Additionally, state can carry application state (like the page the user was on before login) to restore it after redirect.
  • QExplain how DPoP prevents token replay attacks.SeniorReveal
    DPoP (Demonstration of Proof of Possession) binds an access token to a specific client by requiring the client to prove possession of a private key. The client generates a key pair and includes the public key thumbprint in the token request. Every API call includes a DPoP proof JWT signed with the private key, containing the HTTP method and URI. The resource server verifies this proof before accepting the token. Even if the token is stolen, it cannot be replayed from a different client because the attacker lacks the private key, and the proof is tied to a specific endpoint and method.
  • QWhat's the difference between an ID token and an access token at the JWT level?SeniorReveal
    An ID token is a JWT that must contain sub, iss, aud, exp, iat. Its primary purpose is identity — it tells the client who the user is. An access token can be opaque or JWT. If a JWT, it typically contains scopes, a resource-server-specific audience, and a subject. Access tokens are not meant to be read by the client; they are for the resource server. In OIDC flows, the ID token is signed and the access token is also signed (if JWT). The ID token often includes nonce and at_hash; access tokens usually have scope and may not have an aud that matches the client.
  • QHow would you implement OAuth for a microservices architecture with internal service-to-service calls?SeniorReveal
    Use Client Credentials flow for machine-to-machine communication. Each service registers as a confidential client with its own client_id and secret. For user-invoked calls, use token exchange: the edge service (API Gateway) validates the user's access token and then uses the token exchange grant to obtain a new token scoped for the downstream service. Alternatively, use JWT access tokens and propagate them: each internal service validates the same token (checking audience and scope). Use a shared JWKS endpoint for validation. Ensure each service checks the intended audience (its own identifier) to prevent token reuse across services. For revocation, use short-lived tokens and rely on the token expiry.

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

Previous
Time Series Databases
1 / 10 · Security
Next
JWT Authentication Flow