| |

REST API 13 ๐ŸŒ OAuth 2.0 and OpenID Connect (OIDC) Integration

โฑ๏ธ Estimated read: 14 min | |

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

AspectOAuth 2.0OIDC
PurposeAuthorization (API access)Authentication (identity)
TokenAccess tokenID token
ScopeAPI permissionsopenid required
User infoNot definedID token claims

Flows

FlowStatusUse
Authorization Code + PKCERecommendedAll clients
ImplicitDeprecatedDo not use
Client CredentialsValidMachine-to-machine

Validation Checks

CheckPurpose
SignatureToken authenticity
issIssuer match
audAudience match
expNot expired
nonceReplay prevention

PKCE Values

ValuePurpose
code_verifierRandom secret, kept by client
code_challengeSHA-256 hash of verifier
code_challenge_methodS256 (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

PitfallWhy It HappensFix
No ID token issuedMissing openid scopeAdd openid
State mismatchStored in wrong placesessionStorage, same origin
ID token rejectedWrong audUse the client ID
Nonce mismatchNot storedStore and compare
Code exchange failsNo code_verifierSend 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

ItemValue
OAuth 2.0Authorization framework
OIDCAuthentication layer on OAuth 2.0
Access tokenAPI permission
ID tokenUser identity (JWT)
Required scopeopenid
Recommended flowAuthorization Code + PKCE
Deprecated flowImplicit
ValidationSignature, iss, aud, exp, nonce
Public client proofPKCE 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_verifier is sent only in the token request, and the authorization server checks its hash against the code_challenge from the authorization request .
  • The ID token must be validated before it is trusted. The signature, iss, aud, exp, and nonce checks are all required. The nonce check prevents replay, and the iss check 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 sub claim 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!