Security & Auth
16 min read

OAuth 2.0 and OIDC Flows: Authorization Code to PKCE

A comprehensive technical analysis of OAuth 2.0 authorization flows, OpenID Connect (OIDC) identity layer, PKCE security mechanism, and token lifecycle management for secure authentication and authorization implementations.

Resource ServerAuthorization ServerClient AppUser (Browser)Resource ServerAuthorization ServerClient AppUser (Browser)1. Initiate loginGenerate state, nonce, PKCE2. Redirect to AuthZ3. Authorization request + PKCE challenge4. Authenticate & consent5. Redirect with code + state6. Exchange code + PKCE verifier7. Access token + ID token + Refresh token8. API request with access token9. Protected resource
Complete OAuth 2.0 Authorization Code flow with PKCE and OIDC, showing the interaction between user, client, authorization server, and resource server.

OAuth 2.0 is an authorization delegation framework—it lets users grant applications limited access to their resources without sharing credentials. OIDC (OpenID Connect) is an identity layer on top of OAuth—it proves who the user is via ID tokens. The core security model relies on:

Authentication (OIDC)

Authorization (OAuth 2.0)

Token Exchange

Expired

Rotation

OIDC Extension

Fetch Claims

Authorization Code

Access Token

Refresh Token

ID Token

UserInfo Endpoint

ComponentPurposeLifetimeAudience
Authorization CodeOne-time credential for token exchange~10 minutesAuthorization server
Access TokenResource access credential5-60 minutesResource server (API)
Refresh TokenLong-lived credential for new access tokensDays/weeksAuthorization server
ID TokenIdentity proof (JWT with user claims)MinutesClient application
  • PKCE is mandatory for all clients (OAuth 2.1)—prevents authorization code interception
  • Tokens are bearer credentials—possession equals authorization; protect accordingly
  • State prevents CSRF; nonce prevents replay; PKCE prevents interception—all three are required
  • Implicit flow is deprecated—tokens in URLs leak via history, referrer, logs
  • Access tokens are for APIs; ID tokens are for clients—never use ID tokens to call APIs

OAuth 2.0 (RFC 6749) defines four roles that interact during authorization:

RoleDescriptionExample
Resource OwnerEntity granting access to protected resourcesEnd user
Resource ServerServer hosting protected resources, validates access tokensAPI server
ClientApplication requesting access on behalf of resource ownerWeb/mobile app
Authorization ServerIssues tokens after authenticating the resource ownerAuth0, Okta, Keycloak

Design rationale: OAuth separates the client from the resource owner. Instead of the client storing user credentials (the pre-OAuth antipattern), the client obtains tokens with specific scope and lifetime. This enables revocable, scoped access without credential exposure.

Clients are classified by their ability to maintain credential confidentiality:

TypeCan Store Secrets?ExamplesToken Strategy
ConfidentialYesServer-side web appsClient secret + PKCE
PublicNoSPAs, mobile apps, CLIsPKCE only (no secret)

OAuth 2.1 (draft-14): The distinction matters less now—PKCE is mandatory for all clients. Confidential clients still use secrets for additional security, but secrets alone are insufficient.

EndpointPurposeHTTP Method
AuthorizationObtain user consent via redirectGET
TokenExchange grants for tokensPOST
RevocationInvalidate tokensPOST
IntrospectionValidate token metadataPOST
UserInfo (OIDC)Fetch user profile claimsGET/POST

The Authorization Code flow with PKCE (Proof Key for Code Exchange) is the only recommended flow for all client types as of OAuth 2.1.

Before initiating the flow, the client generates three security parameters:

pkce-generation.js
import crypto from "crypto"
// PKCE: code_verifier (43-128 chars, cryptographically random)
const codeVerifier = crypto.randomBytes(32).toString("base64url")
// e.g., "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"
// PKCE: code_challenge (SHA256 hash of verifier)
const codeChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url")
// e.g., "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
// CSRF protection
const state = crypto.randomBytes(16).toString("hex")
// OIDC replay protection
const nonce = crypto.randomBytes(16).toString("hex")
// Store in session for validation
session.oauthParams = { codeVerifier, state, nonce }

Why three parameters?

ParameterProtects AgainstValidated By
stateCSRF attacks (forged authorization responses)Client (callback)
nonceID token replay attacksClient (ID token validation)
code_verifier/code_challengeAuthorization code interceptionAuthorization server (token endpoint)

The client redirects the user to the authorization server:

GET /authorize?
response_type=code
&client_id=CLIENT_ID
&redirect_uri=https://client.example/callback
&scope=openid profile email
&state=abc123xyz
&nonce=def456uvw
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
HTTP/1.1
Host: auth.example.com

Required parameters:

ParameterPurposeRequirement
response_type=codeRequest authorization codeREQUIRED
client_idClient identifierREQUIRED
redirect_uriCallback URL (exact match required)REQUIRED in OAuth 2.1
code_challengePKCE challengeREQUIRED in OAuth 2.1
code_challenge_methodS256 (SHA256) or plainREQUIRED if challenge present
stateCSRF protectionREQUIRED
scopeRequested permissionsRECOMMENDED
nonceReplay protection (OIDC)REQUIRED for OIDC

The authorization server:

  1. Authenticates the user (login if no session)
  2. Displays consent screen with requested scopes
  3. Records user’s decision

On approval, the authorization server redirects back with the authorization code:

HTTP/1.1 302 Found
Location: https://client.example/callback?
code=SplxlOBeZQQYbYS6WxSbIA
&state=abc123xyz
&iss=https://auth.example.com

Security validation (client-side):

callback-validation.js
3 collapsed lines
// Express callback handler
app.get("/callback", async (req, res) => {
const { code, state, iss, error } = req.query
// Check for error response
if (error) {
return res.status(400).json({ error: req.query.error_description })
}
// Validate state (CSRF protection)
if (state !== req.session.oauthParams.state) {
return res.status(400).json({ error: "State mismatch - CSRF detected" })
}
// Validate issuer (mix-up attack protection, RFC 9207)
if (iss !== EXPECTED_ISSUER) {
return res.status(400).json({ error: "Issuer mismatch" })
}
4 collapsed lines
// Proceed to token exchange...
const tokens = await exchangeCodeForTokens(code)
res.json(tokens)
})

RFC 9207: The iss parameter in the authorization response prevents mix-up attacks when clients use multiple authorization servers. Always validate it matches the expected issuer.

The client exchanges the authorization code for tokens at the token endpoint:

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://client.example/callback
&client_id=CLIENT_ID
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

For confidential clients, add client authentication:

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic base64(client_id:client_secret)
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://client.example/callback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk

PKCE server validation:

pkce-validation.js
// Authorization server validates PKCE
function validatePKCE(codeChallenge, codeVerifier, method) {
if (method === "S256") {
const computedChallenge = crypto.createHash("sha256").update(codeVerifier).digest("base64url")
return computedChallenge === codeChallenge
}
// 'plain' method (SHOULD NOT be used)
return codeVerifier === codeChallenge
}
{
"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6ImF0K2p3dCJ9...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "8xLOxBtZp8",
"scope": "openid profile email",
"id_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9..."
}

PKCE (Proof Key for Code Exchange, RFC 7636) prevents authorization code interception attacks.

Authorization ServerMalicious AppLegitimate AppUserAuthorization ServerMalicious AppLegitimate AppUserWithout PKCE, code returns via redirect1. Start login2. Authorization request3. Authenticate4. Malicious app intercepts code(same custom URI scheme)5. Exchange code for tokens6. Tokens issued to attacker!

Attack scenario: On mobile platforms, multiple apps can register the same custom URI scheme (com.example.app://). A malicious app intercepts the redirect containing the authorization code and exchanges it for tokens.

With PKCE: The authorization server binds the code to the code_challenge. Without the code_verifier (which only the legitimate app possesses), the malicious app cannot complete the exchange.

RequirementValue
EntropyMinimum 256 bits (32 bytes)
Character set[A-Z] / [a-z] / [0-9] / "-" / "." / "_" / "~"
Length43-128 characters
GenerationCryptographic random number generator
MethodAlgorithmRecommendation
S256BASE64URL(SHA256(code_verifier))MUST support; SHOULD use
plaincode_challenge = code_verifierSHOULD NOT use (fallback only)

Design rationale: S256 is preferred because even if the code_challenge is leaked (e.g., in browser history), the original code_verifier cannot be derived. With plain, leaking the challenge equals leaking the verifier.

OAuth 2.1 draft-14, Section 7.6: “Clients MUST use code_challenge and code_verifier and authorization servers MUST enforce their use.”

OAuth 2.1 makes PKCE mandatory for all clients—public and confidential. This acknowledges that:

  1. Client secrets can leak (supply chain attacks, compromised dependencies)
  2. PKCE provides defense-in-depth even when secrets are used
  3. A single secure pattern simplifies implementation

OIDC extends OAuth 2.0 to provide authentication (proving who the user is) in addition to OAuth’s authorization (proving what the user can access).

The ID token is a JWT containing identity claims about the authenticated user:

{
"iss": "https://auth.example.com",
"sub": "user_12345",
"aud": "CLIENT_ID",
"exp": 1704153600,
"iat": 1704150000,
"auth_time": 1704149900,
"nonce": "def456uvw",
"acr": "urn:mace:incommon:iap:silver",
"amr": ["pwd", "mfa"],
"at_hash": "x4Q8HQ2_VFbP...",
"name": "Jane Doe",
"email": "jane@example.com",
"email_verified": true
}

Required claims (per OIDC Core 1.0):

ClaimDescriptionValidation
issIssuer identifier (HTTPS URL)MUST match expected issuer
subSubject identifier (max 255 chars, locally unique)Unique user ID within issuer
audAudience—MUST contain client_idReject if client_id not present
expExpiration timeReject if current time > exp
iatIssued at timeUsed for clock validation

Contextually required claims:

ClaimDescriptionWhen Required
nonceReplay protection valueMUST be present if sent in request
auth_timeTime of authenticationWhen max_age requested
acrAuthentication Context Class ReferenceWhen requested as Essential
amrAuthentication Methods ReferencesIndicates methods used (pwd, otp, etc.)
at_hashAccess token hashWhen token issued with ID token
azpAuthorized partyWhen aud contains multiple values
id-token-validation.js
5 collapsed lines
import jwt from "jsonwebtoken"
import jwksClient from "jwks-rsa"
async function validateIdToken(idToken, expectedIssuer, clientId, nonce) {
// 1. Decode header to get key ID
const decoded = jwt.decode(idToken, { complete: true })
const { kid, alg } = decoded.header
// 2. Fetch signing key from JWKS endpoint
const client = jwksClient({ jwksUri: `${expectedIssuer}/.well-known/jwks.json` })
const key = await client.getSigningKey(kid)
// 3. Verify signature and decode claims
const claims = jwt.verify(idToken, key.getPublicKey(), {
algorithms: [alg], // Explicitly allowlist algorithm
issuer: expectedIssuer,
audience: clientId,
})
// 4. Validate nonce (replay protection)
if (claims.nonce !== nonce) {
throw new Error("Nonce mismatch - potential replay attack")
}
// 5. Validate auth_time if max_age was used
if (claims.auth_time && maxAgeUsed) {
const authAge = Math.floor(Date.now() / 1000) - claims.auth_time
if (authAge > maxAge) {
throw new Error("Authentication too old - re-authentication required")
}
}
// 6. Validate at_hash if present (binds ID token to access token)
if (claims.at_hash) {
const expectedHash = computeAtHash(accessToken, alg)
if (claims.at_hash !== expectedHash) {
throw new Error("Access token hash mismatch")
}
}
11 collapsed lines
return claims
}
// Compute at_hash per OIDC Core 3.1.3.6
function computeAtHash(accessToken, alg) {
const hashAlg = alg === "RS256" ? "sha256" : "sha512"
const hash = crypto.createHash(hashAlg).update(accessToken).digest()
const halfHash = hash.slice(0, hash.length / 2)
return halfHash.toString("base64url")
}
AspectID TokenAccess TokenRefresh Token
ProtocolOIDC onlyOAuth 2.0 / OIDCOAuth 2.0 / OIDC
PurposeProve user identityAuthorize API accessObtain new access tokens
FormatAlways JWTJWT or opaqueTypically opaque
AudienceClient applicationResource server (API)Authorization server
ValidationClient validates locallyResource server validatesAuth server only
LifetimeShort (minutes)Short (5-60 min)Long (days/weeks)
ContainsUser identity claimsScopes, permissionsToken family reference

Critical distinction: ID tokens prove who the user is (for the client). Access tokens prove what the user can do (for the API). Never use an ID token to call APIs—it’s semantically wrong and often insecure (audience mismatch).

The UserInfo endpoint returns claims about the authenticated user:

GET /userinfo HTTP/1.1
Host: auth.example.com
Authorization: Bearer <access_token>
{
"sub": "user_12345",
"name": "Jane Doe",
"given_name": "Jane",
"family_name": "Doe",
"email": "jane@example.com",
"email_verified": true,
"picture": "https://example.com/jane.jpg"
}

When to use UserInfo vs ID Token:

  • ID Token: Get claims at authentication time (single request)
  • UserInfo: Fetch additional claims later, refresh claims without re-authentication

Refresh tokens enable long-lived sessions without long-lived access tokens.

POST /token HTTP/1.1
Host: auth.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=CLIENT_ID
&scope=openid profile

Response (with rotation):

{
"access_token": "new_access_token...",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "new_refresh_token",
"scope": "openid profile"
}

Rotation issues a new refresh token with each use, invalidating the previous one:

Authorization ServerClientAuthorization ServerClientInitial grantFirst refreshSecond refreshrefresh_token_1refresh_token_1Invalidate refresh_token_1access_token_2, refresh_token_2refresh_token_2Invalidate refresh_token_2access_token_3, refresh_token_3

If a previously-used refresh token is presented, it indicates token theft:

Authorization ServerLegitimate ClientAttackerAuthorization ServerLegitimate ClientAttackerAttacker steals refresh_token_1Attacker tries stolen tokenrefresh_token_2 also revokedrefresh_token_1refresh_token_2refresh_token_1 (already used!)Detect reuse → Revoke entire token familyError: token_revoked

Implementation considerations:

refresh-reuse-detection.js
8 collapsed lines
const GRACE_PERIOD_MS = 5000 // 5 seconds for network retries
async function handleRefreshToken(refreshToken) {
const tokenRecord = await db.findRefreshToken(refreshToken)
if (!tokenRecord) {
throw new OAuthError("invalid_grant", "Unknown refresh token")
}
// Check if token was already used
if (tokenRecord.usedAt) {
const timeSinceUse = Date.now() - tokenRecord.usedAt
// Grace period for legitimate retries (network failures)
if (timeSinceUse < GRACE_PERIOD_MS) {
// Return same tokens issued during grace period
return tokenRecord.issuedTokens
}
// Outside grace period - potential theft!
await db.revokeTokenFamily(tokenRecord.familyId)
throw new OAuthError("invalid_grant", "Token reuse detected")
}
// Mark as used and issue new tokens
await db.markTokenUsed(refreshToken, Date.now())
const newTokens = await issueTokens(tokenRecord.userId, tokenRecord.scopes)
await db.storeIssuedTokens(refreshToken, newTokens)
3 collapsed lines
return newTokens
}

Trade-offs of rotation:

BenefitCost
Stolen tokens expire fasterDatabase write on every refresh
Reuse detection possibleNetwork failures can lock out users
Limits attacker windowMore complex state management

Alternative: Sender-constrained tokens (DPoP/mTLS) avoid rotation overhead by binding tokens to cryptographic keys.


StorageXSS Vulnerable?Recommendation
localStorageYesMUST NOT use for tokens
sessionStorageYesMUST NOT use for tokens
JavaScript memoryNo (unless XSS)RECOMMENDED for access tokens
HttpOnly cookiesNoRECOMMENDED for refresh tokens

Backend-for-Frontend (BFF) Pattern (most secure for SPAs):

External

Backend

Browser

Session cookie

OAuth flow

Store tokens

Access token

SPA Client

BFF Server

Session Store

Auth Server

Resource Server

  • Browser never sees OAuth tokens
  • BFF maintains server-side session
  • Session ID in HttpOnly, Secure, SameSite=Strict cookie
  • BFF proxies API requests with access token
PlatformRecommended StorageNotes
iOSKeychain ServicesEncrypted, hardware-backed
AndroidEncryptedSharedPreferencesUses Android Keystore
BothSecure Enclave/TEEStrongest protection when available
ios-keychain-storage.swift
4 collapsed lines
// iOS: Store refresh token in Keychain
import Security
func storeRefreshToken(_ token: String, for userId: String) -> Bool {
let query: [String: Any] = [
kSecClass as String: kSecClassGenericPassword,
kSecAttrAccount as String: userId,
kSecAttrService as String: "oauth-refresh-token",
kSecValueData as String: token.data(using: .utf8)!,
kSecAttrAccessible as String: kSecAttrAccessibleWhenUnlockedThisDeviceOnly
]
// Delete existing item first
SecItemDelete(query as CFDictionary)
// Add new item
let status = SecItemAdd(query as CFDictionary, nil)
return status == errSecSuccess
}

RFC 8252 defines OAuth for native applications:

RequirementRationale
Use external user-agent (system browser)Enables SSO, prevents credential theft
MUST NOT use embedded WebViewsHost app can inject JS, capture cookies
PKCE is mandatoryMultiple apps can claim same URI scheme

Redirect URI options:

TypeFormatPlatform
Claimed HTTPShttps://app.example.com/oauthiOS Universal Links, Android App Links
Loopbackhttp://127.0.0.1:{port}/callbackDesktop apps (any port)
Private URI schemecom.example.app:/callbackMobile apps

DPoP (RFC 9449) sender-constrains tokens to prevent stolen tokens from being usable by attackers.

Resource ServerAuthorization ServerClientResource ServerAuthorization ServerClientGenerate ephemeral key pairCreate DPoP proof JWT(signed with private key)Token request + DPoP headerBind token to public keyDPoP-bound access token(cnf claim with JWK thumbprint)Create new DPoP proof(includes access token hash)API request + DPoP headerVerify proof signature matches token bindingProtected resource

Header:

{
"typ": "dpop+jwt",
"alg": "ES256",
"jwk": {
"kty": "EC",
"crv": "P-256",
"x": "l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y": "9VE4jf_Ok_o64tbkMYAPVS-9hQp9A7v9oy_A9C1_2RY"
}
}

Payload (for token request):

{
"jti": "e7d7c7a9-1234-5678-abcd-ef0123456789",
"htm": "POST",
"htu": "https://auth.example.com/token",
"iat": 1704150000
}

Payload (for resource request):

{
"jti": "f8e8d8b9-2345-6789-bcde-f01234567890",
"htm": "GET",
"htu": "https://api.example.com/resource",
"iat": 1704150100,
"ath": "fUHyO2r2Z3DZ53EsNrWBb0xWXoaNy59IiKCAqksmQEo"
}
ClaimDescriptionWhen Required
jtiUnique identifier (UUID v4)Always
htmHTTP methodAlways
htuHTTP target URI (no query/fragment)Always
iatIssued at timestampAlways
athAccess token hashFor resource requests
nonceServer-provided nonceWhen required by server

Security benefit: Even if an attacker steals the access token, they cannot use it without the private key that signed the DPoP proofs.


Attack: Malicious app intercepts authorization code via shared redirect URI.

Mitigation: PKCE (mandatory in OAuth 2.1).

Attack: Attacker tricks user into completing OAuth flow with attacker’s account.

Mitigation: state parameter—validate it matches the value sent in the request.

Attack: Attacker replays captured ID token to authenticate as victim.

Mitigation: nonce parameter—include in request, validate in ID token.

Attack: When client supports multiple authorization servers, attacker tricks client into sending tokens to wrong server.

Mitigation:

  • Validate iss parameter in authorization response (RFC 9207)
  • Validate iss claim in ID token matches expected issuer
  • Use distinct redirect_uri per authorization server

Attack: Attacker crafts authorization request with malicious redirect_uri to steal authorization code.

Mitigation: Exact string matching for redirect_uri validation (no wildcards, no patterns).

redirect-uri-validation.js
// Registration: Store exact URIs only
const registeredRedirectUris = ["https://client.example.com/callback", "https://client.example.com/oauth/callback"]
// Validation: Exact string match
function validateRedirectUri(requestedUri) {
// MUST be exact match - no wildcards, patterns, or normalization
return registeredRedirectUris.includes(requestedUri)
}
// MUST NOT allow:
// - https://client.example.com/* (wildcard)
// - https://*.example.com/callback (subdomain wildcard)
// - Pattern matching or regex

Attack: Access tokens in URL fragments leak via Referer header.

Mitigation:

  • Use Authorization Code flow (not Implicit)
  • Set Referrer-Policy: no-referrer header
  • Use response_mode=form_post (OIDC)

Attack: Authorization server accepts partial URI matches, enabling redirect to attacker-controlled subdomain.

Mitigation: Per RFC 9700 (OAuth Security BCP): “Authorization servers MUST utilize exact string matching” for redirect URI validation.


OAuth 2.1 (currently draft-14, expected RFC in 2026) consolidates OAuth 2.0 with security best practices from RFC 9700.

FlowReason for Removal
Implicit (response_type=token)Tokens in URL fragments leak via history, referrer, logs
Resource Owner Password CredentialsViolates OAuth’s core principle—never share credentials with third parties
RequirementOAuth 2.0OAuth 2.1
PKCE for public clientsRECOMMENDEDMUST
PKCE for confidential clientsNot mentionedSHOULD
Exact redirect URI matchingSHOULDMUST
Bearer tokens in query stringsAllowedMUST NOT
Refresh token sender-constraint or rotationNot specifiedMUST (public clients)
HTTPS for all endpointsSHOULDMUST (except loopback)
  1. Implement PKCE for all clients, including confidential
  2. Remove Implicit flow support; migrate to Authorization Code + PKCE
  3. Remove ROPC if implemented; migrate to proper redirect flow
  4. Enforce exact redirect URI matching—no wildcards
  5. Implement refresh token rotation or DPoP for public clients
  6. Never send tokens in query strings—use headers or POST body

OAuth 2.0 and OIDC provide a robust framework for authorization and authentication, but secure implementation requires understanding the threat model and applying defense-in-depth:

  1. Authorization Code + PKCE is the only recommended flow—use it for all clients
  2. ID tokens prove identity (for clients); access tokens prove authorization (for APIs)—never confuse them
  3. State, nonce, and PKCE work together—all three are required
  4. Token storage must match platform capabilities—HttpOnly cookies for web, Keychain/Keystore for mobile
  5. Refresh token rotation or sender-constraining (DPoP) limits the blast radius of token theft
  6. OAuth 2.1 codifies best practices—adopt its requirements now

The complexity exists because the threat model is real. Authorization code interception, CSRF, replay attacks, and token theft are documented, exploited vulnerabilities. Every security parameter exists because of a specific attack it prevents.

  • HTTP fundamentals (cookies, headers, redirects, CORS)
  • Cryptographic basics (symmetric vs asymmetric, hashing, JWTs)
  • Session management patterns
TermDefinition
Authorization CodeShort-lived credential exchanged for tokens; single-use, bound to client and PKCE
CSRFCross-Site Request Forgery—attack forcing user to execute unwanted actions
DPoPDemonstrating Proof of Possession—mechanism for sender-constraining tokens (RFC 9449)
ID TokenJWT containing identity claims about the authenticated user (OIDC)
OIDCOpenID Connect—identity layer on OAuth 2.0 for authentication
PKCEProof Key for Code Exchange—prevents authorization code interception (RFC 7636)
Refresh TokenLong-lived credential for obtaining new access tokens without user interaction
Sender-ConstraintBinding a token to the client that requested it, preventing use by others
  • OAuth 2.0 is authorization (what you can access); OIDC is authentication (who you are)
  • Authorization Code + PKCE is mandatory for all clients in OAuth 2.1
  • state prevents CSRF; nonce prevents replay; PKCE prevents code interception—use all three
  • ID tokens are for clients; access tokens are for APIs—never interchange them
  • Refresh token rotation detects theft; DPoP prevents stolen token use
  • Store tokens appropriately: memory/HttpOnly cookies for web; Keychain/Keystore for mobile
  • OAuth 2.1 removes Implicit and ROPC flows; mandates PKCE and exact redirect URI matching

Core Specifications:

Security Specifications:

Implementation Guidance:

Read more