OIDC & OAuth 2.0 Implementation: Architecting Secure Modern Authentication

OAuth 2.0 and OpenID Connect are the two specifications that let one system trust another system’s answer to “who is this user, and what may they do?” — and almost every serious identity vulnerability of the last decade came from implementing them approximately rather than exactly. This guide covers the parts that decide whether your deployment is sound: which grant belongs to which client, what the authorization code exchange actually protects, how discovery and key rotation work, and which parameters you must validate on the way back.

This is the entry point for the whole OIDC and OAuth track on this site: the authorization code flow with PKCE for public clients, identity provider configuration and claim handling, secure token refresh and rotation, token revocation on logout, and wiring OIDC into web frameworks like Next.js, Remix, and FastAPI.

Architecture at a Glance

The canonical topology for a browser client is the backend-for-frontend: the single-page app never holds a token, the BFF performs the code exchange over a back channel and keeps the resulting tokens server-side, and the resource API validates a short-lived access token on every call against keys it fetched from the provider’s JWKS endpoint.

Backend-for-frontend topology for OIDC in a browser application The browser talks only to the backend-for-frontend, which exchanges the authorization code with the identity provider, stores refresh tokens in a token store, and calls the resource API with a bearer access token; the resource API verifies signatures using the provider's published key set. Browser / SPA holds a cookie only BFF / auth server confidential client Identity provider authorize · token · JWKS Resource API verifies every call Token store refresh families session cookie code exchange bearer token JWKS store / rotate
The browser is outside every token boundary. That single property is what makes the rest of the hardening in this guide tractable.

Core Concepts: OAuth 2.0 Versus OpenID Connect

The two specifications answer different questions, and conflating them is the root of a surprising number of production bugs — most memorably, treating an access token as proof of identity.

OAuth 2.0 (RFC 6749) is purely a delegated authorization framework. It lets a client obtain limited access to a protected resource without ever seeing the user’s credentials, and it defines four roles: the resource owner (the user), the client (your application), the authorization server (which issues tokens), and the resource server (which accepts them). Its currency is the access token, whose meaning is entirely defined by its scope and aud — it says what may be done, not who is doing it.

OpenID Connect layers authentication on top. Requesting the openid scope makes the authorization server additionally issue an ID token: a JWT, signed with RS256 or ES256, carrying iss, sub, aud, exp, iat, and the nonce you sent. The ID token is a statement to your client that a specific user authenticated at a specific time. It is not a credential for calling APIs, and sending it as a bearer token to a resource server is a bug even when it happens to work.

What OAuth 2.0 provides versus what OpenID Connect adds Two columns: the OAuth 2.0 authorization layer provides access tokens, scopes, grants and the token endpoint; OpenID Connect adds the ID token, the userinfo endpoint, the nonce and discovery metadata on top of it. OAuth 2.0 — authorization Access token · what may be done Scopes · least privilege Grants · code, client credentials Token and revocation endpoints Answers: may this client call this API? OpenID Connect — identity ID token · who authenticated nonce · replay binding userinfo · profile claims Discovery · well-known metadata Answers: who is the user, and when?
An access token is a capability; an ID token is a receipt. Using one where the other belongs is the most common OIDC integration error.

Choosing a Grant for Your Client

Grant selection follows from one question — can this client keep a secret? — and one deprecation: the implicit grant and the resource-owner password grant are gone from current OAuth security guidance and should not appear in new code.

Selecting an OAuth grant from the client type A decision path: if no end user is present use client credentials; if the client cannot hold a secret use the authorization code flow with PKCE, ideally behind a backend-for-frontend; if it can hold a secret use the authorization code flow with PKCE as defence in depth. Is an end user present? no Client credentials machine-to-machine, no refresh yes Can it keep a secret? no Code flow + PKCE public client — put a BFF in front SPA, mobile, desktop yes Code flow + secret keep PKCE anyway server-rendered apps Never: implicit grant · password grant · tokens in the URL fragment
Three destinations, one question each. Note that a confidential client still uses PKCE — it costs one hash and removes an entire class of code-injection attacks.

A confidential client runs on a server you control and can hold a client_secret that never reaches a user agent. A public client — a single-page app, a mobile app, a desktop binary — cannot, because anything shipped to the user can be extracted from it. Public clients must use the authorization code flow with PKCE (RFC 7636), which binds the authorization request to the token exchange with a hash the attacker cannot reproduce; native apps additionally follow RFC 8252 for redirect handling. The mechanics, including the exact verifier and challenge derivation, are in implementing the authorization code flow with PKCE.

// PKCE pair generation (RFC 7636) — S256 only; the "plain" method exists but should not be used.
import { createHash, randomBytes } from "node:crypto";

export function generatePkcePair() {
  const verifier = randomBytes(32).toString("base64url");           // 43-char high-entropy string
  const challenge = createHash("sha256").update(verifier).digest("base64url");
  return { verifier, challenge, method: "S256" as const };
}

What the Code Exchange Actually Buys You

It is worth being precise about why the authorization code flow has survived while the implicit grant was withdrawn, because the reasoning drives several other decisions in this guide. In the implicit grant, the authorization server put the access token directly in the redirect URL’s fragment. That meant the token passed through the browser’s address bar, was visible to every script running on the page, landed in browser history, and could leak through a Referer header or a badly configured logging proxy. There was also no way for the authorization server to authenticate the client at the moment it handed over the credential, because the handover happened in a front-channel redirect that anyone can trigger.

The code flow replaces that single dangerous step with two safer ones. The front channel — the redirect the browser follows — carries only an authorization code: a short-lived, single-use reference that is worthless on its own. The back channel — a direct server-to-server POST to the token endpoint — carries the client authentication and, for public clients, the PKCE verifier. An attacker who steals the code from a log, a shared device, or a mis-scoped redirect still cannot redeem it, because they cannot produce the verifier whose SHA-256 hash the authorization server recorded at the start of the flow.

That property is why the code should be treated as single-use and short-lived on your side too. Redeem it immediately on callback, mark it consumed, and reject a second presentation loudly rather than silently issuing a second set of tokens — a replayed code is either a bug in your own retry logic or an attacker holding a copy, and both deserve an alert. Bind the code to the same session that started the flow, so a code obtained in one browser cannot be completed in another.

Discovery, JWKS and Key Rotation

Hard-coding endpoint URLs and a single public key is how integrations break at 3am on the day the provider rotates a key. Every OIDC provider publishes discovery metadata at /.well-known/openid-configuration, and that document names the authorization endpoint, the token endpoint, the revocation endpoint, the supported algorithms, and — critically — the JWKS URI where the current signing keys live.

Discovery and key resolution at verification time The verifier reads the well-known discovery document once, caches the JWKS URI, then resolves the key id from the token header against a cached key set, refetching only when an unknown key id appears. Discovery doc .well-known jwks_uri cached 10 min Key set by kid two keys overlap kid found → verify signature no network call on the hot path kid unknown → refetch once rate-limited, then reject the token
A rate-limited refetch on an unknown key id is what makes provider-side key rotation invisible to your users — and what stops an attacker forcing unbounded outbound requests.
// Discovery + JWKS with bounded caching (Node.js / jose)
import { jwtVerify, createRemoteJWKSet } from "jose";

const discovery = await fetch("https://idp.example.com/.well-known/openid-configuration").then((r) => r.json());
const JWKS = createRemoteJWKSet(new URL(discovery.jwks_uri), {
  cacheMaxAge: 600_000,      // bounded staleness: a rotated key lands within ten minutes
  cooldownDuration: 30_000,  // an unknown kid cannot trigger a refetch storm
});

export async function verifyAccessToken(token: string) {
  const { payload } = await jwtVerify(token, JWKS, {
    issuer: discovery.issuer,
    audience: "https://api.example.com",
    algorithms: ["RS256", "ES256"],
    clockTolerance: "30s",
  });
  return payload;
}

Validate the discovery document’s issuer against the URL you fetched it from, and confirm the endpoints it names live on the same origin — otherwise a compromised metadata document can point your token exchange at an attacker’s server. Full provider-side configuration, including redirect-URI allowlisting and claim mapping, is covered in configuring identity providers for OIDC.

Token Lifecycle and Session State

Three tokens with three jobs and three very different lifetimes come out of a successful code exchange. Treating them as interchangeable is how long-lived credentials end up in places they should never be.

Relative lifetimes and storage of the three OIDC tokens The ID token is consumed once at login, the access token lives five to fifteen minutes and travels to resource servers, and the refresh token lives days but never leaves the server side. Lifetime and blast radius ID token used once at login, then discarded — never sent to an API Access token 5–15 minutes · sent to resource servers · scoped by aud Refresh token days to weeks · server side only · rotated and reuse-detected on every exchange issued
The refresh token is the long-lived credential, which is exactly why it belongs in a server-side store with rotation and reuse detection rather than anywhere near a browser.

Access tokens should be short — five to fifteen minutes for browser-facing APIs — so that expiry does most of your revocation work. Refresh tokens should be single-use: every exchange issues a new one and invalidates the old, so replaying a stolen refresh token collides with a token that has already been consumed and trips the alarm described in detecting refresh token reuse with rotation.

// Refresh rotation with family revocation on reuse (stateful)
export async function rotateRefreshToken(userId: string, presented: string, store: SessionStore) {
  const session = await store.findByRefreshToken(presented);

  // A token that is unknown, already rotated, or belongs to someone else means replay.
  if (!session || session.userId !== userId || session.consumedAt) {
    await store.revokeFamily(session?.familyId ?? presented);
    throw new Error("refresh token reuse detected — family revoked");
  }

  const next = randomBytes(48).toString("base64url");
  await store.consume(session.id, { refreshToken: next, rotatedAt: Date.now() });
  return next;
}

Security Hardening and Threat Mitigation

Nearly every OAuth attack in the wild targets a parameter someone forgot to validate on the way back from the provider. The controls are small, cheap, and non-negotiable.

OAuth callback attacks and the parameter check that stops each one Four attacks paired with defences: login CSRF is stopped by binding and checking state, ID token replay by the nonce, code interception by PKCE, and open redirection by exact redirect URI matching. Attack What stops it Login CSRF — victim signed in as attacker state bound to the session, compared exactly ID token replay from another session nonce echoed in the token and verified Authorization code interception PKCE S256 verifier the attacker cannot derive Open redirect to an attacker origin exact redirect_uri match, no wildcards
Four checks, four attack classes. Every one of them is a comparison you either perform on the callback or do not — there is no partial credit.
  • state must be cryptographically random, stored against the pre-login session, and compared exactly on callback. Without it an attacker can complete a login flow in the victim’s browser using their own authorization code, silently binding the victim’s activity to the attacker’s account.
  • nonce must be sent on the authorization request and matched against the claim in the returned ID token, which blocks replay of a token minted for a different session.
  • redirect_uri must match a pre-registered value by exact string comparison. Wildcards, prefix matches, and “any path on this host” rules have all been used to exfiltrate codes.
  • Tokens never travel in URLs. A token in a query string lands in browser history, server access logs, and the Referer header of the next outbound request.

Pair those with transport hardening: TLS 1.3 everywhere, HSTS with preload, and HttpOnly; Secure; SameSite=Lax on the BFF session cookie as described in configuring secure cookie flags in production.

Two further checks catch the attacks that survive the basics. Validate the at_hash claim when the provider supplies it: it binds the ID token to the access token issued alongside it, so a mixed pair from two different flows is rejected rather than silently accepted. And pin the maximum authentication age you are willing to accept for sensitive operations using the auth_time claim — a user who authenticated eight hours ago should not be able to change their password or add a payment method without re-authenticating, which is the same reasoning behind step-up authentication for sensitive actions.

Finally, remember that consent is a security control, not a legal formality. A provider that silently re-issues tokens for a scope the user never approved gives an attacker a quiet path to widen access; requesting incremental scopes at the moment they are needed keeps the consent screen meaningful and keeps the blast radius of any single token small.

Operating OIDC in Production

Most OIDC outages are not cryptographic; they are operational. The provider changes a default, a certificate expires, a redirect URI is registered for staging but not for production, or a clock drifts far enough that freshly minted tokens look like they come from the future. A short list of operational habits removes nearly all of them.

Treat provider configuration as code. Redirect URIs, allowed scopes, token lifetimes, and signing algorithms belong in your infrastructure repository, applied through the provider’s management API, and reviewed like any other change. Configuration drift between environments is the single most common cause of “it works in staging” login failures, and a console-only change leaves no audit trail when someone widens a redirect URI to debug something and never narrows it again.

Monitor the four signals that matter. Track the token endpoint’s error rate broken down by error code, the rate of failed signature verifications, the age of the newest key in your JWKS cache, and the ratio of refresh exchanges to logins. A spike in invalid_grant usually means clock skew or a code being replayed; a spike in signature failures means a rotation your verifiers have not picked up; and a refresh-to-login ratio that suddenly climbs can mean a client is looping on a failing refresh, which will exhaust the provider’s rate limits before anyone notices.

Keep clocks disciplined. Every timestamp check in the protocol — exp, iat, nbf, and the maximum authentication age — assumes your servers agree with the provider’s clock to within a few seconds. Run NTP everywhere, allow a 30-second tolerance in your verifier, and never “fix” a validation failure by widening tolerance to minutes, which silently extends the life of every stolen token.

Plan for provider outages. Decide in advance whether a login outage should fail closed (no new sessions, existing sessions continue) or fail open in some limited way for internal tooling. Cache the discovery document and the key set with a bounded lifetime so an outage of the metadata endpoint does not immediately break verification of tokens you have already issued, and make sure the cache is warm before a deployment rather than fetched lazily on the first user request.

Test the unhappy paths. The flows that break in production are the ones nobody exercises: an expired code, a mismatched state, a user who cancels consent, a refresh token that was revoked while a tab was open, and a second browser tab completing a login the first tab started. Each one should have an integration test asserting the user sees a sensible screen rather than a stack trace, and that your logs contain enough context to tell the difference between an attack and a user closing a laptop lid.

Compliance and Standards Alignment

Requirement Specification What to show an auditor
Authorization code flow RFC 6749 §4.1 The callback handler validating state before exchanging the code
Proof key for code exchange RFC 7636 code_challenge_method=S256 on every authorization request
Native app redirect handling RFC 8252 Claimed HTTPS redirects or a private-use scheme with PKCE
Token revocation RFC 7009 A revocation call on logout and evidence it runs
JWT best practices RFC 8725 The algorithm allowlist and kid-based key selection
ID token validation OpenID Connect Core §3.1.3.7 iss, aud, exp, and nonce checks in code
Token introspection RFC 7662 The introspection call, or the justification for local validation

Choosing a Flow and Token Strategy

Decision Public client (SPA / mobile) Confidential client (server-rendered) Service-to-service
Grant type Authorization code + PKCE (RFC 7636) Authorization code (+ PKCE as defence in depth) Client credentials (RFC 6749 §4.4)
Client secret None — public client Stored server-side, never shipped to the browser Secrets manager or workload identity
Token storage Held by a BFF in HttpOnly cookies; the app holds none Server-side session store keyed by a cookie In memory, re-requested on expiry
ID token Required (openid scope), validated server-side Required, validated server-side Not issued — no end user present
Refresh handling Rotating refresh tokens bound to the session Rotating refresh tokens with reuse detection No refresh token; re-request instead
Signing algorithm RS256 or ES256 only; reject HS256 RS256 or ES256 only; reject HS256 RS256 or ES256 only
Logout Revoke at the BFF (RFC 7009) plus provider logout Destroy the session and revoke the refresh token Discard the token

Multi-Tenant and Federated Deployments

Business-to-business products add a dimension the specifications leave to you: which identity provider should a given user authenticate against? The usual answer is home-realm discovery — the user types an email address, you map its domain to a tenant, and you redirect to that tenant’s provider. Three details decide whether this is safe.

First, the domain-to-tenant mapping must be verified, not asserted. Anyone can claim to own a domain during signup; require a DNS record or an email challenge before a domain routes to a customer’s provider, or you have built an account-takeover path for every user with that email suffix.

Second, the iss check has to become per-tenant. A single-tenant integration hard-codes one issuer; a multi-tenant one must look up the expected issuer for the tenant the login belongs to and reject a token from any other, otherwise a customer with their own provider can mint tokens accepted for a different customer’s data.

Third, claim mapping must default to nothing. Group and role claims arrive from a system your customer controls, so treat them as untrusted input mapped through an explicit allowlist into your own permission model — never as authoritative role names. The mechanics are in mapping OIDC claims to application roles, and the enforcement model that consumes those roles is covered in the access control guide.

Session lifetime deserves a tenant-level policy too. Enterprise customers routinely require shorter idle timeouts, mandatory re-authentication for administrative actions, and the ability to terminate every session for their users on demand — all of which are straightforward if sessions are server-side records keyed by tenant, and awkward if they are self-contained tokens scattered across resource servers.

Implementation Pathways

The OIDC and OAuth track breaks into focused walkthroughs you can implement independently:

Read the walkthroughs in the order above if you are building from scratch: the flow first, then the provider configuration it depends on, then the lifecycle controls that keep it safe once real users are on it.

Frequently Asked Questions

Can I send the ID token to my API instead of the access token?

No. The ID token’s audience is your client, not your API, so a correctly implemented resource server will reject it — and one that accepts it has disabled the audience check that stops tokens minted for other applications. Ask for an access token with your API as the audience, and keep the ID token on the server that performed the login.

Do confidential clients really need PKCE?

Current OAuth security guidance says yes, and it costs almost nothing. PKCE defends against authorization code injection — an attacker who obtains a code (through a logging leak, a referrer header, or a mis-scoped redirect) cannot redeem it without the verifier. The client secret alone does not stop that, because the attacker is attacking your legitimate client, not impersonating it.

Should I validate tokens locally or call the introspection endpoint?

Validate locally with the published key set for normal request handling — it is a signature check with no network round trip, and it scales. Use introspection (RFC 7662) when you need the authorization server’s live opinion, typically for opaque tokens or for high-value operations where you must know the token has not been revoked in the last few minutes. Many systems do both: local validation everywhere, introspection on the few endpoints that move money.

How do I handle a provider that only supports HS256?

Treat it as a constraint on your architecture, not just a configuration value. HS256 is a shared secret, so every service that validates a token must hold the signing key — which means any one of them can also mint tokens. Keep validation in a single trusted component (the BFF or gateway), never distribute the secret to resource servers, and press the provider for asymmetric signing, which is what makes distributed verification safe.

What breaks first when the identity provider rotates a key?

Verifiers with an unbounded JWKS cache and no refetch-on-unknown-kid path. They keep the old key set, see a token signed with a kid they do not recognise, and return 401 for every request until something restarts them. Set a cache maximum age of five to ten minutes, refetch once on an unknown key id, and rate-limit that refetch so a stream of bogus tokens cannot turn into an outbound request flood.