REST API 13 ๐ OAuth 2.0 and OpenID Connect (OIDC) Integration
OAuth 2.0 and OpenID Connect are often mentioned together, but they answer different questions. OAuth 2.0 is an authorization framework: it lets an application obtain limited access to a protected resource on behalf of a user, without the user sharing their password with the application . OpenID Connect is an authentication layer built on top of OAuth 2.0: it adds an ID token that proves who the user is, so the application can establish a local session . An access token grants permission to call an API. An ID token proves identity. Confusing the two is the most common integration mistake, and it leads to applications that treat a permission slip as proof of identity.
The integration is not a single flow but a choice of flows. The Authorization Code flow with PKCE is the current recommendation for all client types, including single-page applications and native apps . The Implicit flow, which returned tokens directly in the URL fragment, is deprecated because the tokens are exposed in browser history and scripts . The client secret is a confidential-client concept; a public client like an SPA cannot keep a secret, which is why PKCE exists. The integration is therefore a set of decisions: which flow, how to validate the ID token, how to store the tokens, and how to protect the redirect.
Key point: OAuth 2.0 is authorization (access tokens for APIs); OIDC is authentication (ID tokens for identity). Use the Authorization Code flow with PKCE for every client type. Validate the ID token’s signature, iss, aud, exp, and nonce before trusting it. Never treat an access token as proof of identity.
Why the two protocols are separate
The delegation problem. OAuth 2.0 exists so that a user can grant a third-party application access to their data without giving the application their password. The user authenticates directly with the authorization server, the server issues an access token to the application, and the application presents the token to the resource server . The application never sees the password. The access token carries scopes that define what the application can do, not who the user is.
The identity problem. OAuth 2.0 does not define how the application learns the user’s identity. The access token may be opaque, and the resource server may not return an identity claim. OIDC fills this gap by adding an ID token, which is a signed JWT containing claims like sub (subject), iss (issuer), aud (audience), exp (expiry), and nonce . The application validates the ID token and establishes a session based on the sub claim.
The mix-up problem. A client that interacts with multiple authorization servers must use the iss parameter or the ID token’s iss claim to ensure the token came from the expected server. Without this check, an attacker can trick the client into accepting a token from a malicious server . The OWASP cheatsheet lists this as an essential basic, alongside the state parameter and PKCE.
The PKCE problem. A public client cannot keep a client secret. An authorization code intercepted from a public client can be redeemed by an attacker at the token endpoint. PKCE (Proof Key for Code Exchange) solves this: the client generates a random code_verifier, sends its SHA-256 hash as the code_challenge in the authorization request, and sends the original verifier in the token request. The authorization server checks that the hash matches, which proves the token request came from the same client that started the flow . PKCE is now recommended for all client types, not just public ones .
a. The Authorization Code flow with PKCE
The flow has six steps: discovery, authorization request, callback handling, code exchange, ID token validation, and session creation.
// Step 1: Discover the provider metadata
// The /.well-known/openid-configuration document lists the endpoints
const discovery = await fetch('https://idp.example.com/.well-known/openid-configuration')
const config = await discovery.json()
// Step 2: Generate the PKCE verifier and challenge
// The verifier is 43-128 random characters
// The challenge is the base64url-encoded SHA-256 of the verifier
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
// Generate a random state for CSRF protection
const state = base64url(crypto.getRandomValues(new Uint8Array(16)))
// Store the verifier and state in the session
sessionStorage.setItem('pkce', JSON.stringify({ verifier, state }))
// Step 3: Redirect to the authorization endpoint
const params = new URLSearchParams({
response_type: 'code', // Authorization Code flow
client_id: 'my-spa-client', // The registered client ID
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email', // openid is required for OIDC
state, // CSRF protection
code_challenge: challenge, // PKCE challenge
code_challenge_method: 'S256', // SHA-256, not plain
nonce: base64url(crypto.getRandomValues(new Uint8Array(16))), // replay protection
})
window.location.assign(`${config.authorization_endpoint}?${params}`)
The scope must include openid for OIDC. Without it, the authorization server does not issue an ID token. The nonce is a random value that the ID token must echo back; it prevents replay of an old ID token . The state is stored in the session and compared on the callback; a mismatch means the callback is not the response to the request the client sent.
// Step 4: Handle the callback
// The authorization server redirects to the redirect_uri with code and state
const params = new URLSearchParams(window.location.search)
const code = params.get('code')
const returnedState = params.get('state')
// Retrieve the stored verifier and state
const stored = JSON.parse(sessionStorage.getItem('pkce'))
sessionStorage.removeItem('pkce')
// Compare the state; reject on mismatch
if (returnedState !== stored.state) {
throw new Error('State mismatch: possible CSRF')
}
// Step 5: Exchange the code for tokens
// The code_verifier proves this is the same client that started the flow
const tokenResponse = await fetch(config.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: 'https://app.example.com/callback',
client_id: 'my-spa-client',
code_verifier: stored.verifier,
}),
})
const tokens = await tokenResponse.json()
// tokens contains access_token, id_token, and maybe refresh_token
The public client does not send a client_secret in the token request. The code_verifier is the proof of possession. The authorization server hashes the verifier and compares it to the code_challenge it stored with the authorization code . A stolen authorization code cannot be redeemed without the verifier.
b. Validating the ID token
The ID token is a JWT, and the application must validate it before trusting any claim. The validation has five checks: signature, issuer, audience, expiry, and nonce.
// Step 6: Validate the ID token
// The signature is checked against the provider's JWKS endpoint
// The issuer, audience, expiry, and nonce are checked as claims
import { jwtVerify, createRemoteJWKSet } from 'jose'
// The JWKS is the provider's public key set
const JWKS = createRemoteJWKSet(new URL(config.jwks_uri))
try {
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
// The issuer must match the discovery document
issuer: config.issuer,
// The audience must be the client ID
audience: 'my-spa-client',
// The current time must be before exp
clockTolerance: 30,
})
// The nonce must match the one sent in the authorization request
if (payload.nonce !== storedNonce) {
throw new Error('Nonce mismatch: possible replay')
}
// The sub claim is the user's stable identifier
const userId = payload.sub
// The other claims are profile data
const email = payload.email
} catch (err) {
// Any failure means the token is not trustworthy
throw new Error('Invalid ID token')
}
The signature check is what distinguishes a real ID token from a forged one. The iss check ensures the token came from the expected provider. The aud check ensures the token was issued for this application, not a different one. The nonce check ensures the token is a response to this specific authentication request, not a replay of an old one . The OWASP cheatsheet lists the nonce and the iss check as the mix-up and replay defenses.
The access token is used for API calls, not for identity. It is sent in the Authorization: Bearer header, and the resource server validates it against the provider’s JWKS or introspection endpoint. The application does not parse the access token for user identity; that is the ID token’s job .
c. The client types and the secret decision
The flow is the same for public and confidential clients, but the token request differs. A confidential client (a server-rendered web app) has a client_secret and sends it in the token request, typically via HTTP Basic authentication. A public client (an SPA or native app) has no secret and relies on PKCE.
// Confidential client token request
// The client_secret is sent via HTTP Basic authentication
const basic = btoa(`${clientId}:${clientSecret}`)
const tokenResponse = await fetch(config.token_endpoint, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
Authorization: `Basic ${basic}`,
},
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri,
code_verifier: verifier, // PKCE is still recommended
}),
})
The confidential client can keep the secret because the code runs on the server, not in the browser. The public client cannot, which is why PKCE is mandatory for it . The OWASP cheatsheet notes that PKCE is recommended for confidential clients as well, because it protects against authorization code injection even when a secret is present.
The session storage is the other decision. The ID token and access token can be kept in memory. The refresh token, if issued, should be in an HttpOnly, Secure, SameSite cookie, not in localStorage. A single XSS vulnerability in a localStorage implementation exposes the refresh token, and with it the ability to mint new access tokens indefinitely.
Complete Example Session
The following session builds a complete OIDC login flow for a public client: discovery, PKCE generation, authorization request, callback handling, code exchange, ID token validation, and session creation.
// ============================================
// PART 1: DISCOVERY
// ============================================
const config = await fetch('https://idp.example.com/.well-known/openid-configuration').then(r => r.json())
// config.authorization_endpoint, config.token_endpoint, config.jwks_uri, config.issuer
// ============================================
// PART 2: PKCE AND STATE
// ============================================
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)))
const challenge = base64url(await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier)))
const state = base64url(crypto.getRandomValues(new Uint8Array(16)))
const nonce = base64url(crypto.getRandomValues(new Uint8Array(16)))
sessionStorage.setItem('oidc', JSON.stringify({ verifier, state, nonce }))
// ============================================
// PART 3: AUTHORIZATION REQUEST
// ============================================
const params = new URLSearchParams({
response_type: 'code',
client_id: 'spa-client',
redirect_uri: 'https://app.example.com/callback',
scope: 'openid profile email',
state,
code_challenge: challenge,
code_challenge_method: 'S256',
nonce,
})
window.location.assign(`${config.authorization_endpoint}?${params}`)
// ============================================
// PART 4: CALLBACK AND CODE EXCHANGE
// ============================================
const q = new URLSearchParams(window.location.search)
const stored = JSON.parse(sessionStorage.getItem('oidc'))
if (q.get('state') !== stored.state) throw new Error('state mismatch')
const tokens = await fetch(config.token_endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code: q.get('code'),
redirect_uri: 'https://app.example.com/callback',
client_id: 'spa-client',
code_verifier: stored.verifier,
}),
}).then(r => r.json())
// ============================================
// PART 5: ID TOKEN VALIDATION
// ============================================
const JWKS = createRemoteJWKSet(new URL(config.jwks_uri))
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: config.issuer,
audience: 'spa-client',
clockTolerance: 30,
})
if (payload.nonce !== stored.nonce) throw new Error('nonce mismatch')
// ============================================
// PART 6: SESSION
// ============================================
// The sub claim is the identity
const userId = payload.sub
// The access token is for API calls, not identity
const accessToken = tokens.access_token
The six parts cover discovery, PKCE and state generation, the authorization request, the callback and code exchange, the ID token validation, and the session creation. What the session demonstrates is that the flow is a sequence of redirects and back-channel requests, and every value that crosses a boundary is checked against a stored value.
Quick Reference
OAuth 2.0 vs OIDC
| Aspect | OAuth 2.0 | OIDC |
|---|---|---|
| Purpose | Authorization (API access) | Authentication (identity) |
| Token | Access token | ID token |
| Scope | API permissions | openid required |
| User info | Not defined | ID token claims |
Flows
| Flow | Status | Use |
|---|---|---|
| Authorization Code + PKCE | Recommended | All clients |
| Implicit | Deprecated | Do not use |
| Client Credentials | Valid | Machine-to-machine |
Validation Checks
| Check | Purpose |
|---|---|
| Signature | Token authenticity |
iss | Issuer match |
aud | Audience match |
exp | Not expired |
nonce | Replay prevention |
PKCE Values
| Value | Purpose |
|---|---|
code_verifier | Random secret, kept by client |
code_challenge | SHA-256 hash of verifier |
code_challenge_method | S256 (not plain) |
Best Practices
โ Do This:
// Use PKCE for every client type
code_challenge_method: 'S256'
// Validate the nonce
if (payload.nonce !== storedNonce) throw new Error()
// Store the refresh token in an HttpOnly cookie
res.cookie('refresh', token, { httpOnly: true, secure: true })
โ Don’t Do This:
// Use the implicit flow
response_type: 'token' // deprecated
// Trust the access token for identity
const userId = tokens.access_token // wrong
// Skip the state check
// CSRF vulnerability
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
| No ID token issued | Missing openid scope | Add openid |
| State mismatch | Stored in wrong place | sessionStorage, same origin |
| ID token rejected | Wrong aud | Use the client ID |
| Nonce mismatch | Not stored | Store and compare |
| Code exchange fails | No code_verifier | Send the verifier |
Real-World Examples
1. Authorization request
response_type=code&client_id=spa&scope=openid+profile&state=xyz&code_challenge=abc&code_challenge_method=S256
2. Code exchange
grant_type=authorization_code&code=...&code_verifier=...
3. ID token validation
jwtVerify(idToken, JWKS, { issuer, audience })
4. Nonce check
if (payload.nonce !== stored.nonce) throw new Error()
5. Confidential client secret
Authorization: Basic base64(client_id:client_secret)
6. Discovery document
fetch('https://idp/.well-known/openid-configuration')
7. JWKS
createRemoteJWKSet(new URL(config.jwks_uri))
8. Refresh token cookie
res.cookie('refresh', token, { httpOnly: true, secure: true })
9. State check
if (q.get('state') !== stored.state) throw new Error()
10. PKCE challenge
base64url(await crypto.subtle.digest('SHA-256', verifier))
Visual
OAuth 2.0 vs OIDC
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ OAUTH 2.0 โ
โ โ authorization โ
โ โ access token โ
โ โ API scopes โ
โ โ
โ OIDC (on top of OAuth 2.0) โ
โ โ authentication โ
โ โ ID token (JWT) โ
โ โ user identity (sub, email) โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Authorization Code + PKCE
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ Client generates verifier + challenge โ
โ โ โ
โ โผ โ
โ Redirect to authorize with challenge โ
โ โ โ
โ โผ โ
โ User authenticates, server returns code โ
โ โ โ
โ โผ โ
โ Client POSTs code + verifier to token โ
โ โ โ
โ โผ โ
โ Server checks hash, returns tokens โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
ID Token Validation
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ ID token (JWT) โ
โ โ โ
โ โผ โ
โ Verify signature with JWKS โ
โ โ โ
โ โผ โ
โ Check iss, aud, exp, nonce โ
โ โ โ
โ โผ โ
โ Trust sub as user identity โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Summary
| Item | Value |
|---|---|
| OAuth 2.0 | Authorization framework |
| OIDC | Authentication layer on OAuth 2.0 |
| Access token | API permission |
| ID token | User identity (JWT) |
| Required scope | openid |
| Recommended flow | Authorization Code + PKCE |
| Deprecated flow | Implicit |
| Validation | Signature, iss, aud, exp, nonce |
| Public client proof | PKCE code_verifier |
Key takeaways:
- OAuth 2.0 and OIDC answer different questions. OAuth 2.0 grants access to an API. OIDC proves who the user is. The access token is for the API, and the ID token is for the application’s session .
- The Authorization Code flow with PKCE is the recommendation for every client type. The Implicit flow is deprecated because it exposes tokens in the URL fragment. PKCE protects the code exchange for public clients that cannot keep a secret .
- PKCE is the public client’s proof of possession. The
code_verifieris sent only in the token request, and the authorization server checks its hash against thecode_challengefrom the authorization request . - The ID token must be validated before it is trusted. The signature,
iss,aud,exp, andnoncechecks are all required. Thenoncecheck prevents replay, and theisscheck prevents mix-up attacks . - The access token is not identity. The application should not parse the access token for user information. The ID token’s
subclaim is the stable identifier . - The client secret is a confidential-client concept. A public client cannot keep a secret, which is why PKCE exists. Confidential clients should still use PKCE, but they authenticate with a secret in addition .
Remember: OAuth 2.0 and OIDC are a delegation and identity layer, not a login form. The integration is a sequence of redirects and back-channel requests, and every value that crosses a boundaryโthe state, the nonce, the code_verifier, the ID token’s claimsโmust be checked against the value that the client stored. The access token grants permission to call the API; the ID token proves who the user is. The PKCE verifier is what proves the token request came from the same client that started the flow. Get the flow wrong and the tokens are exposed. Get the validation wrong and a forged or replayed token is accepted. The protocol is the framework, and the validation is the security.
Stop using slow, ad-bloated tool sites! ๐คฎ
๐ Search “KandZ Tools” on Google to use many professional utilities for free.
KandZ.me is the ultimate minimalist hub for:
โ
Finance (Mortgage, Interest, Inflation)
โ
Tech (Base64, JSON, Dev Suite, IP)
โ
Health (BMI, BMR, TDEE)
โ
Productivity (Timer, Workspace, QR)
โก๏ธ Fast & Private
๐ No data leaves your device
๐ 100% Free
๐ Use it now: https://tools.kandz.me
๐ Bookmark itโyouโll need it later!