OAuth 2.0 & OpenID Connect Implementation
OAuth 2.0 and OpenID Connect (OIDC) are the delegation and federated-identity backbone of nearly every modern application, yet the majority of production integrations ship with subtle defects that quietly undermine the whole trust model: a wildcard redirect_uri, a missing state check, an ID token accepted without signature verification, or a refresh token that never rotates. Getting the protocol right is not about wiring up a library — it is about enforcing a precise sequence of cryptographic bindings across an untrusted browser, your application boundary, and the authorization server. This guide is part of Secure Authentication & Session Architecture and covers the correct implementation of the Authorization Code flow with PKCE (RFC 7636), the reasons the implicit flow is now deprecated, exact-match redirect validation, and the token-validation discipline OIDC requires. It maps directly to OWASP ASVS V3 and V51, SOC 2 CC6.1, NIST SP 800-63C federation assurance, and the OAuth 2.0 Security Best Current Practice (RFC 9700). The controls here complement session fixation prevention at the point where a federated login mints a local session, and they determine everything about how you approach token storage patterns once the tokens are in hand.
Two child guides go deeper than this overview: PKCE authorization code flow for SPAs for browser and mobile public clients, and validating OIDC ID tokens in Node.js for the server-side verification layer.
Threat Anatomy
OAuth and OIDC are not vulnerable protocols — but they are protocols with many moving parts, and every parameter that is left unvalidated becomes an attacker-controlled input. The adversary’s goal is almost always the same: obtain a token that was minted for a legitimate user, or trick the flow into binding a legitimate user’s session to an attacker-controlled account.
Attacker perspective. Consider the authorization code as a bearer secret in transit. If it travels through an attacker-influenced channel, it is game over. A public client on a mobile device historically registered a custom URI scheme (myapp://callback); a malicious app on the same device could register the same scheme and intercept the code — the classic authorization code interception attack that PKCE was designed to defeat. On the web, a lax redirect_uri policy lets an attacker redirect the code to a host they control (redirect_uri manipulation / open redirect). If the client omits state, an attacker can complete their own authorization, capture the resulting code, and feed it to a victim’s browser so the victim’s session is silently bound to the attacker’s identity — a login CSRF on the callback. When a client talks to multiple identity providers, a mix-up attack confuses which provider issued a code, letting the attacker leak a code minted at an honest provider to a malicious one. Finally, token substitution and code injection swap a token or code issued for one context into another session that lacks the binding to detect the swap.
MITRE ATT&CK coverage. Stolen or replayed tokens map to T1550 (Use Alternate Authentication Material), specifically the application-access-token technique where an adversary authenticates with a valid OAuth token rather than credentials. Phishing and interception aimed at harvesting access or refresh tokens map to T1528 (Steal Application Access Token). These are execution-free attacks: the adversary never exploits a memory bug, they exploit a missing check.
Real-world impact. Missing redirect_uri exact matching has repeatedly enabled full account takeover in bug-bounty reports across major platforms. Accepting an ID token signed with alg: none, or verifying it with the wrong key, lets an attacker forge identity claims and impersonate any user. A refresh token that never rotates turns a single interception into indefinite, undetectable access.
For the client-side redemption mechanics that neutralize code interception, see PKCE authorization code flow for SPAs.
OAuth/OIDC Threat Reference
| Threat | Attacker Action | Enabling Weakness | Primary Control |
|---|---|---|---|
| Authorization code interception | Capture the code in the redirect channel | Public client with no proof-of-possession | PKCE S256 code_challenge / code_verifier |
| redirect_uri manipulation | Redirect the code to an attacker host | Wildcard or prefix-match redirect policy | Exact-string redirect_uri allowlist |
| Login CSRF on callback | Bind a victim session to an attacker code | Missing or unverified state |
Session-bound, single-use state |
| Mix-up attack | Leak a code across identity providers | Ambiguous issuer on multi-IdP callback | Per-provider state + iss response check |
| Token / code substitution | Inject a token minted for another session | ID token accepted without nonce/aud |
Verify aud, iss, nonce, signature |
| Refresh token replay | Reuse a stolen long-lived token | Static, non-rotating refresh tokens | Rotation + reuse detection |
Prerequisites & Scope
Before wiring any endpoint, confirm the following preconditions and design decisions:
- Client type is decided. A browser SPA or native mobile app is a public client (no secret); a server-side web app or API-to-API integration is a confidential client (holds a secret or private key). This choice drives token endpoint authentication.
- Authorization server supports the discovery document. The provider exposes
/.well-known/openid-configurationand a JWKS endpoint. All examples assume OIDC discovery rather than hard-coded endpoints. - PKCE with
S256is available. The provider advertisesS256incode_challenge_methods_supported. Never fall back to theplainmethod. - Exact redirect URIs are enumerable. Every legitimate callback URL is known ahead of time and registered verbatim — no wildcards, no user-controlled path or query segments.
- A place to store transient flow state exists.
state,nonce, andcode_verifiermust survive the round trip in a per-session store (HTTP-only cookie, server session, or, for SPAs, session storage scoped to the flow). - Token handling strategy is chosen. Where tokens live after issuance is a security decision covered in token storage patterns; resolve it before you write the callback handler.
Mitigation Architecture
The secure implementation is the Authorization Code flow augmented with PKCE, state, and nonce. The architectural principle is proof-of-possession across an untrusted channel: the browser proves it initiated the flow (via state), the token exchange proves the redeemer is the same party that started it (via the PKCE code_verifier), and the ID token proves it answers this specific authentication (via nonce). The authorization code is useless to anyone who intercepts it because redeeming it requires a secret that never left the initiating client.
Deprecated vs. Recommended Flows
| Concern | Implicit flow (deprecated) | Authorization Code + PKCE (recommended) |
|---|---|---|
| Token delivery | Access token in redirect fragment | One-time code, tokens via back-channel POST |
| Interception impact | Token exposed in history/referrer | Code useless without code_verifier |
| Proof of possession | None | PKCE binds redemption to initiator |
| Replay defense | Weak | state + nonce + short-lived code |
| RFC 9700 status | Must not be used | Preferred for all client types |
Step-by-Step Implementation
Step 1 — Register the Client and Exact Redirect URIs (ASVS V51.1.1, NIST SP 800-63C)
Register the application at the authorization server and declare its type. Enumerate every callback URL verbatim. The provider must perform exact string comparison — scheme, host, port, and path all matching — with no wildcards and no user-controlled segments. An open redirect anywhere on a registered origin becomes a token-exfiltration primitive, so the redirect endpoint must be a dedicated, static path.
{
"client_name": "reporting-spa",
"application_type": "web",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"redirect_uris": [
"https://app.example.com/auth/callback"
],
"code_challenge_methods_supported": ["S256"]
}
For a public client, token_endpoint_auth_method is none and there is no secret. A confidential client uses client_secret_basic or, preferably, private_key_jwt. Never embed a confidential secret in a browser or mobile bundle — a client that cannot keep a secret is public by definition.
Step 2 — Attach PKCE, State, and Nonce to the Authorize Request (ASVS V51.2.1, RFC 7636)
Every authorization request carries three anti-abuse parameters. The code_challenge is the SHA-256 (S256) hash of a fresh, high-entropy code_verifier. The state is an unguessable value bound to the browser session for CSRF protection. The nonce binds the eventual ID token to this authentication event.
import { randomBytes, createHash } from "node:crypto";
const b64url = (b: Buffer) => b.toString("base64url");
export function buildAuthorizeUrl(authzEndpoint: string, clientId: string, redirectUri: string) {
const codeVerifier = b64url(randomBytes(32));
const codeChallenge = b64url(createHash("sha256").update(codeVerifier).digest());
const state = b64url(randomBytes(16));
const nonce = b64url(randomBytes(16));
// Persist these in a per-session, HTTP-only store keyed to this browser
const flow = { codeVerifier, state, nonce };
const url = new URL(authzEndpoint);
url.search = new URLSearchParams({
response_type: "code",
client_id: clientId,
redirect_uri: redirectUri,
scope: "openid profile email", // request the minimum scopes needed
code_challenge: codeChallenge,
code_challenge_method: "S256",
state,
nonce,
}).toString();
return { authorizeUrl: url.toString(), flow };
}
Request the narrowest scope the feature requires. Scope minimization limits what a leaked access token can reach — an over-scoped token turns a minor leak into a major breach.
Step 3 — Exchange the Code and Validate Tokens (ASVS V51.3.1, SOC 2 CC6.1)
On the callback, first confirm the returned state equals the stored value — reject immediately on mismatch to defeat login CSRF. Then exchange the code together with the original code_verifier at the token endpoint. Finally, validate the ID token before trusting any claim in it.
export async function handleCallback(params: URLSearchParams, flow: FlowState, cfg: OidcConfig) {
const returnedState = params.get("state");
if (!returnedState || returnedState !== flow.state) {
throw new Error("state mismatch — possible CSRF, aborting");
}
const code = params.get("code");
if (!code) throw new Error("missing authorization code");
const res = await fetch(cfg.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code,
redirect_uri: cfg.redirectUri,
client_id: cfg.clientId,
code_verifier: flow.codeVerifier, // proves this is the initiating client
}),
});
if (!res.ok) throw new Error(`token endpoint returned ${res.status}`);
const tokens = await res.json();
// Validate the ID token signature, iss, aud, exp, and nonce before trusting it
await verifyIdToken(tokens.id_token, { ...cfg, expectedNonce: flow.nonce });
return tokens;
}
The ID token is a JWT and must be verified against the provider’s JWKS with the correct algorithm — never trusted on inspection alone. The full verification routine is covered in validating OIDC ID tokens in Node.js.
Step 4 — Handle Refresh with Rotation (ASVS V51.4.1, RFC 9700)
Long-lived refresh tokens are the highest-value target in the whole flow. Enable refresh token rotation: every redemption issues a new refresh token and invalidates the old one. If a previously used refresh token is presented again, treat it as evidence of theft — revoke the entire token family and force re-authentication.
export async function refresh(refreshToken: string, cfg: OidcConfig) {
const res = await fetch(cfg.token_endpoint, {
method: "POST",
headers: { "content-type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: cfg.clientId,
}),
});
if (res.status === 400) {
// Reused or revoked token — the server rejected it; kill the session
await revokeSessionFamily(refreshToken);
throw new Error("refresh rejected — token reuse detected");
}
return res.json(); // contains a fresh refresh_token that replaces the old one
}
Edge Cases & Bypass Patterns
Wildcard and Prefix redirect_uri Matching
A provider that matches redirect_uri by prefix or allows wildcards lets an attacker append a path or subdomain they control and receive the code. Always require exact-string registration, and treat any registered origin’s open redirect as equivalent to leaking the code. Reject requests whose redirect_uri is not byte-for-byte identical to a registered value.
Missing or Reused state (Login CSRF)
If the callback handler does not compare state, an attacker can pre-seed a code and log a victim into the attacker’s account, then harvest the victim’s activity. state must be single-use, bound to the browser session, and rejected on any mismatch or absence — never merely logged.
Mix-up Attacks Across Multiple Providers
When a client supports several identity providers, an attacker can start a flow at a malicious provider and relay the code toward an honest one. Bind state to the specific provider that was invoked, and where the provider returns an iss parameter on the callback, verify it identifies the issuer you expected before redeeming the code.
Accepting Tokens Without Signature or nonce Verification
Decoding a JWT is not validating it. An ID token accepted without verifying its signature against the JWKS, or without checking that nonce matches the value you sent, allows token injection and replay. Reject alg: none and any algorithm that does not match the provider’s advertised signing algorithm.
PKCE Downgrade to plain
If the client sends code_challenge_method=plain, the challenge equals the verifier and offers no protection against interception. Refuse to fall back to plain; require S256 on both the client and, where configurable, the authorization server.
Automated Testing & CI Validation
Integration Test: Callback Rejects Tampered state
import { describe, it, expect } from "vitest";
import { handleCallback } from "./oauth";
describe("OAuth callback hardening", () => {
const flow = { state: "expected-state", nonce: "n", codeVerifier: "v" };
it("rejects a mismatched state parameter", async () => {
const params = new URLSearchParams({ code: "abc", state: "attacker-state" });
await expect(handleCallback(params, flow, cfg)).rejects.toThrow(/state mismatch/);
});
it("rejects a callback with no code", async () => {
const params = new URLSearchParams({ state: "expected-state" });
await expect(handleCallback(params, flow, cfg)).rejects.toThrow(/missing authorization code/);
});
});
CI/CD Gate: Configuration and Static Checks
# .github/workflows/oauth-gate.yml
name: OAuth/OIDC Config Gate
on: [push, pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Reject implicit flow and plain PKCE
run: |
! grep -rniE "response_type=.?token" src/ || (echo "implicit flow detected" && exit 1)
! grep -rniE "code_challenge_method.{0,6}plain" src/ || (echo "plain PKCE detected" && exit 1)
- name: Reject wildcard redirect URIs in client config
run: |
! grep -rnE "redirect_uris.*\*" config/ || (echo "wildcard redirect_uri detected" && exit 1)
- name: Semgrep — unverified JWT decode
uses: semgrep/semgrep-action@v1
with:
config: |
rules:
- id: jwt-decode-without-verify
pattern: jwt.decode(...)
message: "Use a verifying JWKS check, not decode(), for ID tokens"
severity: ERROR
languages: [javascript, typescript]
Compliance Mapping
| Framework | Control | Satisfied By |
|---|---|---|
| OWASP ASVS v4.0 | V51.1.1 | Exact-match redirect_uri registration, no wildcards |
| OWASP ASVS v4.0 | V51.2.1 | PKCE S256, state, and nonce on every authorize request |
| OWASP ASVS v4.0 | V3.5.3 | ID token treated as authentication, access token as authorization |
| SOC 2 | CC6.1 | Logical access enforced via validated federated identity |
| SOC 2 | CC7.2 | Refresh reuse detection surfaces credential-theft anomalies |
| NIST SP 800-63C | 7.1 | Federation assertion (ID token) signature and audience validation |
| OAuth 2.0 Security BCP | RFC 9700 §2.1 | Authorization Code + PKCE replaces the implicit flow |
| OAuth 2.0 Security BCP | RFC 9700 §4.14 | Refresh token rotation with reuse detection |
Common Pitfalls Checklist
Frequently Asked Questions
Why is the implicit flow deprecated in favor of Authorization Code with PKCE?
The implicit flow returned access tokens directly in the redirect URI fragment, where they leak into browser history, Referer headers, and any script running on the page. It also offered no proof that the party receiving the token initiated the request. The OAuth 2.0 Security BCP (RFC 9700) deprecates it in favor of the Authorization Code flow with PKCE, which delivers a short-lived, single-use code in the redirect and only exchanges it for tokens over a back channel — and only when the redeemer proves possession of the original code_verifier. An intercepted code is worthless without that verifier.
What is the difference between an ID token and an access token?
An ID token is an OpenID Connect construct: a signed JWT that authenticates the user to your client. You validate its signature and claims to learn who logged in. An access token is an OAuth authorization credential your client presents to a resource server to call an API; its format is opaque to the client by design. The cardinal rules are that clients must never treat an access token as proof of identity, and must never send an ID token to an API as a bearer credential. Conflating the two — for example, using an ID token to authorize API calls — breaks the audience separation the protocol depends on.
How does state differ from nonce in an OIDC flow?
state is an anti-CSRF value bound to the browser session and echoed on the callback; comparing it proves the response answers a request this user actually initiated. nonce is a value embedded in the authorization request and returned inside the ID token; comparing it binds that specific token to a single authentication event, defeating replay and token injection. They protect different stages — state guards the redirect, nonce guards the token — so both are required, not interchangeable.
Do confidential server-side clients still need PKCE?
Yes. RFC 9700 recommends PKCE for every Authorization Code flow, including confidential clients that authenticate with a secret. Client authentication and PKCE defend against different attacks: the secret proves client identity at the token endpoint, while PKCE binds the code to the browser that requested it, defeating interception and code injection regardless of client type. Applying both is defense in depth and materially raises the cost of mix-up and substitution attacks.
Related
- PKCE Authorization Code Flow for SPAs and Mobile Apps — generating the verifier and challenge and redeeming the code in a public client
- Validating OIDC ID Tokens in Node.js — JWKS retrieval, signature verification, and claim validation
- Session Fixation Prevention — hardening the local session minted after a federated login
- Token Storage Patterns — where access and refresh tokens should live after issuance
- Cross-Site Request Forgery (CSRF) Defense — request-forgery controls that reinforce callback protection
- Secure Authentication & Session Architecture — the parent guide covering the full authentication lifecycle