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.
20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.
- 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
Imagine you're staying at a hotel. Instead of giving every restaurant and spa inside the hotel a copy of your passport, the front desk gives you a key card that says 'this guest can use the pool and restaurant, but not the business lounge.' OAuth 2.0 is that key card system — it lets a third-party app do specific things on your behalf without ever seeing your password. OpenID Connect is the hotel also printing your name and photo on the card so the spa knows who you are, not just what you're allowed to do.
In technical terms, OAuth uses tokens instead of passwords. The access token is like a limited-use key card that expires after a short time. The refresh token is like a voucher that lets you get a new key card without showing your passport again.
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.
OAuth 2.0 & OpenID Connect — Delegated Authorization vs. Authentication
OAuth 2.0 is an authorization framework that lets a client application (e.g., a single-page app) obtain limited access to a resource server on behalf of a resource owner, without exposing the owner's credentials. It works by issuing an access token — typically a bearer token — that the client presents to the resource server. OpenID Connect (OIDC) is an identity layer on top of OAuth 2.0 that adds authentication: it returns an ID token (a signed JWT) containing claims about the end-user, so the client can verify who the user is.
In practice, the flow starts with the client redirecting the user to the authorization server. After the user authenticates and consents, the server returns an authorization code to the client (via a redirect URI). The client exchanges that code — along with its client secret — for an access token, an ID token, and optionally a refresh token. The refresh token allows the client to get new access tokens without user interaction, but it must be stored securely. In a browser-based SPA, there is no safe place to store a refresh token — any JavaScript can read it, making it vulnerable to XSS attacks.
Use OAuth 2.0 when you need delegated access to APIs (e.g., "let this app post to my calendar"). Add OIDC when you also need to know who the user is (e.g., login with Google). For SPAs, the best practice is to use the Authorization Code Flow with PKCE (Proof Key for Code Exchange) and avoid refresh tokens entirely — instead, use short-lived access tokens and silent authentication via an iframe or the prompt=none parameter.
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.
- OAuth answers: 'Can this app access my photos?'
- OIDC answers: 'Who is using this app?'
- You need both when your app needs to know the user AND act on their behalf.
- Use OIDC for login; use OAuth scopes for API access.
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.
evil.com after authorization.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 Core Concepts — The Entities You'll Actually Deal With
Most explanations dump a diagram and call it a day. Let's talk about what each piece actually does when things break.
The Resource Owner is your user. They own the data. The Client is your application — the thing asking for access. These are not abstract roles. They are the systems you deploy and the people who use them.
The Authorization Server issues tokens after authentication. This is usually a separate service like Keycloak, Auth0, or a custom OIDC provider. Don't embed it in your monolith unless you enjoy security headaches.
The Resource Server hosts the actual data. Your API. It validates tokens before serving requests. Never trust the client to tell you who they are. The Resource Server must validate every single request.
Scopes define what the client can do — read, write, delete. Access Tokens are opaque strings or JWTs that carry this authorization. They expire. Refresh tokens exist to get new access tokens without re-authenticating the user.
You will debug scope mismatches and expired tokens in production. Understand these actors cold before writing a single line of auth code.
How OAuth 2.0 Works — The Wire Calls You Can't Ignore
Authorization Code flow exists because front-channel tokens are a security hole. The user clicks “Sign in with Google,” your back end receives the redirect with a temporary code, and then your server exchanges that code for an access token via a direct back-channel POST. That exchange is the only call whose response contains the token—no JavaScript ever sees it. The redirect URI must match exactly what you registered, or the authorization server rejects the request. If your token expires after 15 minutes and your refresh token after 30 days, any session beyond that boundary forces a full re-authorization. Every millisecond of token validation latency adds up: verify the token’s signature, audience (aud), issuer (iss), and expiration (exp) before accepting it. Never trust a token sent in a query string; it ends up in server logs and browser history. Use the Authorization: Bearer header instead.
Disadvantages — The Pain OAuth 2.0 Doesn't Solve Out of the Box
OAuth 2.0 gives you a token, but zero guidance on what to put inside it or how to prove who the user is. That means every team must independently solve identity representation, leading to brittle client-side logic that breaks when a token format changes. The spec also omits any standardized encryption or signing mechanism for the authorization code itself, leaving implementations vulnerable to code interception unless explicit PKCE is added. Token revocation is another gap: there is no mandatory way to invalidate a refresh token beyond a server-side blacklist, which introduces stale-token attack windows. Finally, scoping is purely advisory — a client can request scope "admin" and a resource server has no standard way to negotiate reduced access; it must either trust blindly or build custom enforcement. These holes force production systems to layer on OpenID Connect for identity, JWT profiles for structure, and custom revocation logic, adding complexity that the base spec was supposed to avoid.
Leaked Refresh Token in SPA Logs
- Never log tokens anywhere, especially client-side.
- Refresh token rotation is non-negotiable for SPAs.
- Assume every client-side value is compromised — validate server-side.
- Always validate redirect URIs server-side; never trust client-provided redirect parameters.
jwt.io or curl to decode. Check iss, aud, exp claims. The audience must match the resource server's identifier.grant_type parameter: use authorization_code for code flow, client_credentials for client credentials, refresh_token for refresh. Ensure the value is exactly as specified.kid in the token header matches a key. Ensure clock skew is ≤60s. If the server uses symmetric keys (HS256) and you expect RS256, you may be hit by algorithm confusion.curl -v https://auth.example.com/.well-known/openid-configurationtail -f /var/log/nginx/access.log | grep 'authorize'Key takeaways
Common mistakes to avoid
9 patternsStoring tokens in localStorage
Not using PKCE for public clients
Accepting 'alg: none' in JWT validation
{"alg":"none"} and arbitrary payload. If the server doesn't check the algorithm, it accepts the token as valid.alg: none or alg: HS256 when you expect RS256.Using the implicit flow for modern apps
Ignoring token expiry and not handling refresh failures
Failing to rotate refresh tokens
Missing state parameter in authorization request
state parameter in the authorization request and validate it when the redirect returns. Reject mismatches immediately.Not validating the token_type claim
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.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
Interview Questions on This Topic
Explain the difference between OAuth 2.0 and OpenID Connect.
Frequently Asked Questions
20+ years shipping large-scale distributed systems. Notes here come from systems that actually shipped.
That's Security. Mark it forged?
12 min read · try the examples if you haven't