OIDC and OAuth 2.0 Flows
OpenID Connect (OIDC) is a thin identity layer on top of OAuth 2.0. OAuth 2.0 was designed for authorization (delegating API access); OIDC adds the missing piece — telling the app who the user is via an ID token.
For OAuth 2.0 / OIDC from the security angle, see ../../25_security/05_oauth2_oidc.md. This file is the SSO-focused mechanics.
OAuth 2.0 vs OIDC — what’s added
| OAuth 2.0 | OIDC | |
|---|---|---|
| Purpose | delegate API access | + delegate identity |
| Returns | access token (opaque or JWT) | + ID token (always JWT) + UserInfo endpoint |
| Standard claim about user | (none — opaque) | sub (subject identifier) |
| Discovery | (vendor-specific) | .well-known/openid-configuration |
| Scope to request | (resource-defined) | openid (required) + profile, email, etc. |
OIDC = OAuth 2.0 + ID token + standard claims + discovery.
The flows (“grant types”)
Five OAuth 2.0 flows. For SSO of users in a browser, Authorization Code with PKCE is the only correct choice for new code.
| Flow | Use case | Recommended? |
|---|---|---|
| Authorization Code (with PKCE) | web apps, SPAs, mobile apps | YES — the default |
| Authorization Code (without PKCE) | older confidential web apps | OK if confidential client + state |
| Implicit | legacy SPAs (deprecated) | NO |
| Resource Owner Password Credentials (ROPC) | legacy “send user/pass to API” | NO |
| Client Credentials | machine-to-machine, no user | YES, for service auth |
| Device Code | TVs, CLIs, devices without browser | YES, for that use case |
| Refresh Token | renew access token | YES (with rotation) |
Authorization Code with PKCE — the modern default
1. User → App GET /protected
2. App generates code_verifier (random); code_challenge = SHA256(code_verifier)
3. App → User 302 → IdP/authorize?response_type=code&client_id=...
&redirect_uri=https://app/cb
&scope=openid profile email
&state=<random>
&nonce=<random>
&code_challenge=<...>
&code_challenge_method=S256
4. User → IdP (authenticates if no session)
5. IdP → User 302 → https://app/cb?code=<auth_code>&state=<random>
6. App verifies state matches step 3
7. App → IdP POST /token client_id, client_secret (if any),
code=<auth_code>, code_verifier=<original>,
redirect_uri=https://app/cb,
grant_type=authorization_code
8. IdP verifies code_verifier hashes to the code_challenge
9. IdP → App { id_token, access_token, refresh_token, expires_in }
10. App validates id_token (signature, iss, aud, exp, nonce)
11. App → User creates session, redirects to original /protected
Key parameters:
| Param | Purpose |
|---|---|
response_type=code |
this is auth code flow |
scope=openid ... |
openid makes it OIDC (returns ID token); plus profile, email, etc. |
state |
random per-request; CSRF protection (match on callback) |
nonce |
random per-request; in ID token, prevents replay |
code_challenge / code_challenge_method=S256 |
PKCE — proves the same client that started got the code |
redirect_uri |
exact-match registered URI; can’t be tampered |
Why PKCE matters
PKCE (Proof Key for Code Exchange, RFC 7636) was added for public clients that can’t keep a client secret (mobile apps, SPAs). Even without a client secret, the attacker who intercepts the code can’t redeem it because they don’t have the code_verifier.
PKCE flow:
- App generates random
code_verifier. - App sends
code_challenge = SHA256(code_verifier)(base64url) withcode_challenge_method=S256in step 3. - IdP stores the
code_challengeagainst the issued auth code. - App sends
code_verifier(original random value) on token exchange. - IdP recomputes
SHA256(code_verifier)and compares to storedcode_challenge.
Always use PKCE. Even confidential clients. The OAuth 2.1 draft makes PKCE mandatory for all flows.
ID token — the identity statement
The ID token is a JWT issued by the OP (OIDC Provider). It’s the OIDC analog of a SAML assertion.
Claims (selected):
| Claim | Meaning |
|---|---|
iss |
issuer (the OP’s URL) |
sub |
subject — stable opaque user identifier |
aud |
audience — the client_id of your app |
exp |
expiry (Unix time) |
iat |
issued at (Unix time) |
nbf |
not before (Unix time) |
nonce |
the nonce you sent in step 3 |
auth_time |
when the user actually authenticated (may be earlier than this token) |
acr |
authentication context class — e.g. MFA level |
amr |
authentication methods — e.g. ["pwd", "mfa"] |
email, email_verified |
with email scope |
name, given_name, family_name |
with profile scope |
What the RP (Relying Party / your app) MUST verify:
- Signature — using JWKS from
<issuer>/.well-known/jwks.json(see 03_tokens_id_access_refresh.md). issmatches the configured OP.audcontains yourclient_id(andazpif present).exp> now.iatwithin reasonable window (clock skew tolerance).noncematches what you sent (replay protection).
Skip any check, and your app is broken in ways that may be exploitable.
Access token — for API calls
Used to call APIs on behalf of the user. May or may not be a JWT — depends on the OP.
GET /api/me HTTP/1.1
Authorization: Bearer eyJhbGciOi...
The API (resource server) validates the access token — either:
- Locally if it’s a JWT and the API has the OP’s public keys.
- Remotely via introspection (
POST /introspectto OP) if opaque.
OAuth 2.0 doesn’t strictly mandate a format for access tokens. Cloud OPs (Auth0, Okta, Cognito) usually issue JWTs.
Don’t use the ID token to call APIs. ID token’s audience is your app; API’s expected audience is itself. Use the access token.
Refresh token
A long-lived token used to get fresh access tokens without re-authenticating the user.
POST /token
grant_type=refresh_token&refresh_token=<token>&client_id=...
Best practices:
- Refresh token rotation — each refresh issues a new refresh token; old one becomes invalid. Detects token theft (if both old and new are used, that’s two parties — alarm).
- Bind to client — refresh tokens for confidential clients are tied to client_secret; public clients use PKCE + sender constraint.
- Short access tokens, longer refresh — access maybe 15 min; refresh hours to days. Revoke refresh tokens when user logs out.
Refresh token security is critical — it’s effectively persistent access. Store in HttpOnly cookies for browser apps; in secure storage for mobile.
Discovery — .well-known/openid-configuration
Every OIDC provider exposes a metadata endpoint:
GET https://idp.example.com/.well-known/openid-configuration
Returns JSON:
{
"issuer": "https://idp.example.com",
"authorization_endpoint": "https://idp.example.com/authorize",
"token_endpoint": "https://idp.example.com/token",
"userinfo_endpoint": "https://idp.example.com/userinfo",
"jwks_uri": "https://idp.example.com/.well-known/jwks.json",
"end_session_endpoint": "https://idp.example.com/logout",
"revocation_endpoint": "https://idp.example.com/revoke",
"introspection_endpoint": "https://idp.example.com/introspect",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code", "id_token", "token"],
"code_challenge_methods_supported": ["S256"]
}
Your app loads this once at startup; auto-configures endpoints. Don’t hardcode endpoint URLs — the OP can change them.
JWKS — JSON Web Key Set
GET https://idp.example.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "abc123",
"use": "sig",
"n": "...",
"e": "AQAB"
}
]
}
Your app caches these keys; uses them to verify ID token signatures. The token’s header has "kid": "abc123" to indicate which key signed it.
Keys rotate periodically (every few months typically). Cache with a sensible TTL (1–24 hours) and re-fetch on signature failure (handles rotation gracefully).
UserInfo endpoint
For additional claims beyond what’s in the ID token:
GET /userinfo HTTP/1.1
Authorization: Bearer <access_token>
Returns JSON with claims (same shape as ID token claims). Useful when claims would make the ID token too large, or when you don’t want claims in the JWT (which may be logged).
Scopes vs claims
| Scope | Claim | |
|---|---|---|
| What | a request for a set of permissions/data | a specific piece of data in the token |
| Sent by | client in authorize request | OP in token / UserInfo response |
| Example | openid email profile |
email: alice@example.com |
Common OIDC scopes:
| Scope | Claims |
|---|---|
openid (required) |
sub |
profile |
name, family_name, given_name, nickname, … |
email |
email, email_verified |
address |
address |
phone |
phone_number, phone_number_verified |
offline_access |
(no claims; requests refresh token) |
Custom scopes per IdP (e.g. groups, roles) — defined by the IdP, not standard OIDC.
Hybrid flow (rarely used now)
response_type=code id_token
Returns both an auth code AND an ID token in the front-channel redirect. Used historically when the SPA wanted instant identity without a token exchange. Mostly superseded by code+PKCE.
Implicit flow (DEPRECATED)
response_type=token id_token
Tokens returned directly in the URL fragment. Designed for SPAs before CORS-enabled token endpoints. Don’t use — vulnerable to token leakage via referrer headers and browser history. Use code+PKCE instead.
Device flow (RFC 8628)
For input-constrained devices (TVs, CLIs, IoT):
1. Device → IdP POST /device_authorization
2. IdP → Device { device_code, user_code, verification_uri }
3. Device displays "go to https://idp/device, enter ABCD-1234"
4. User opens browser on phone, completes auth
5. Device polls IdP token endpoint with device_code
6. IdP → Device { access_token, ... } after user completes
Used by aws sso login, GitHub CLI, smart TVs.
Common interview confusions
- “OAuth and OIDC are the same.” — OAuth is authorization (delegated API access). OIDC adds identity (ID token, standard claims, discovery). OIDC uses OAuth 2.0 flows underneath.
- “You authenticate users with the access token.” — no, access tokens authorize API calls. Authenticate via the ID token (after validating it).
- “Implicit flow is fine for SPAs.” — deprecated; use code + PKCE.
- “Tokens are opaque random strings.” — access tokens may be; ID tokens are always JWTs.
Interview angle
- “Walk through OIDC authorization code with PKCE.” — generate code_verifier + code_challenge; redirect to IdP with state, nonce, code_challenge; user authenticates; redirect back with code; POST to token endpoint with code + code_verifier; get id_token + access_token + refresh_token; validate id_token (signature, iss, aud, exp, nonce).
- “Why PKCE?” — even for public clients (SPAs, mobile) without a client secret, PKCE proves the entity exchanging the code is the same one that started the flow. Prevents authorization code interception attacks.
- “Difference between
stateandnonce?” —stateis CSRF protection (sent in authorize request, verified in callback).nonceis replay protection on the ID token (sent in authorize, comes back inside the signed ID token). - “What’s the OIDC discovery document?” —
<issuer>/.well-known/openid-configuration. JSON metadata describing endpoints, supported scopes/algorithms, JWKS URI. Your app loads it at startup to auto-configure. - “How does the RP verify an ID token?” — fetch JWKS (cached), check signature with the right kid, validate iss/aud/exp/nbf/nonce, ensure
audincludes your client_id. - “What’s the difference between ID token, access token, and refresh token?” — ID token: identity statement, JWT, for your app. Access token: API authorization, may be JWT or opaque, for resource servers. Refresh token: long-lived, for renewing access tokens without re-auth.
- “Why is implicit flow deprecated?” — tokens in URL fragments leak via browser history, referrer headers, and server logs. No exchange step to prove client identity. Replaced by code + PKCE.