OAuth2 + OIDC — Comprehensive Reference¶
Core Problem OAuth2 Solves¶
Let a client access a user's resources without ever holding the user's password. Scoped, revocable, delegated access.
Four Actors¶
- Resource Owner — the user
- Client — the application requesting access
- Authorization Server — issues tokens after user grants permission
- Resource Server — holds the data, validates tokens before serving it
The Two Core HTTP Calls¶
/authorize (browser) |
/token (backend) |
|
|---|---|---|
| Purpose | ask permission, get consent | redeem proof for tokens |
client_id |
yes | yes |
redirect_uri |
yes | yes (must match) |
scope |
yes — decided here | no — baked into code already |
state |
yes — client sets it | no — client checked it on the redirect |
code |
no — this call produces it | yes — this call consumes it |
client_secret |
never — browser can't hold secrets | yes (confidential clients only) |
grant_type |
no | yes |
Flow 1 — Authorization Code Flow (No PKCE)¶
Client type: confidential (server-side app, can hold a secret — e.g. a BFF)
Step 1 — Browser redirected to auth server¶
GET https://auth.yourdomain.com/authorize?
response_type=code
&client_id=bff-client-123
&redirect_uri=https://app.yourdomain.com/callback
&scope=openid profile invoices.read
&state=xk9f2m1
response_type=code— requests this specific flowclient_id— identifies the pre-registered appredirect_uri— must exactly match a URI registered for this client_id (stops code hijacking via mismatched redirects)scope— access being requested;openidtriggers OIDC behaviorstate— opaque client-generated value, echoed back unchanged; defends against CSRF on the callback
Step 2 — What the auth server does¶
- Looks up
client_id, confirmsredirect_urimatches registration - Shows login page if no active session; shows consent screen if new scopes
- Establishes its own session (cookie) for the user
Step 3 — Redirect back with code¶
GET https://app.yourdomain.com/callback?
code=SplxlOBeZQQYbYS6WxSbIA
&state=xk9f2m1
Client verifies state matches what it sent. Code is short-lived (30-60s), single-use, and worthless alone — redeeming it requires the client_secret too.
Step 4 — Backend-to-backend token exchange¶
POST https://auth.yourdomain.com/token
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://app.yourdomain.com/callback
&client_id=bff-client-123
&client_secret=super-secret-value-only-client-knows
Browser never sees this call. redirect_uri sent again — auth server checks it matches step 1.
Step 5 — Response¶
{
"access_token": "eyJhbGci...",
"id_token": "eyJhbGci...",
"refresh_token": "8xLOxBtZp8",
"token_type": "Bearer",
"expires_in": 900
}
Auth server: looks up code → validates it was issued to this client_id and not expired/used → verifies client_secret → invalidates code (single use) → mints tokens.
Why client_secret matters even with state/redirect_uri checks: state defends the browser redirect against CSRF. redirect_uri matching stops a code being redirected to the wrong endpoint. Neither proves who redeems the code later — an attacker could copy a leaked code into their own POST to /token. client_secret never touches the browser, so it's the only thing proving the /token caller really is the registered client.
Flow 2 — Authorization Code Flow + PKCE¶
Client type: public (SPA, mobile app — cannot safely hold a secret, since it would live in a decompilable binary or readable JS source)
The mechanism¶
Client generates, before calling /authorize:
-
code_verifier— random high-entropy string, kept in memory onlydBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk -
code_challenge— derived by hashing:code_challenge = BASE64URL(SHA256(code_verifier))Algorithm: SHA256, method name sent to server:S256. (Aplainmethod exists — challenge equals verifier unhashed — discouraged.)
One-way property: given code_challenge, you cannot reconstruct code_verifier. Safe to send openly through the browser.
Step 1 — /authorize with PKCE params added¶
GET https://auth.yourdomain.com/authorize?
response_type=code
&client_id=spa-client-456
&redirect_uri=https://app.yourdomain.com/callback
&scope=openid profile invoices.read
&state=xk9f2m1
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
Auth server stores code_challenge + method against the code it's about to issue. state is still present — PKCE does not replace it; they defend different things.
Step 4 — /token with verifier instead of secret¶
POST https://auth.yourdomain.com/token
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https://app.yourdomain.com/callback
&client_id=spa-client-456
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
No client_secret. Auth server hashes the incoming code_verifier with SHA256, compares to the stored code_challenge. Match proves this caller is the same party that initiated the flow — nobody else ever saw the raw verifier.
PKCE Refresh Flow¶
PKCE only matters at the original code exchange — it plays no role afterward.
POST https://auth.yourdomain.com/token
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=spa-client-456
No client_secret (public client never had one), no re-proof of PKCE. Trust instead comes from:
- Refresh token bound to client_id at issuance — auth server checks it matches
- Mandatory rotation — every refresh issues a new refresh token and invalidates the old one; if a stolen token is later reused after the legitimate client already rotated, the mismatch signals theft and the auth server can revoke the whole token family
- Optionally, sender-constrained tokens (DPoP) binding the refresh token to a specific device
Other Grant Types¶
Client Credentials — service-to-service, no user¶
POST https://auth.yourdomain.com/token
grant_type=client_credentials
&client_id=image-processor-service
&client_secret=service-secret-value
&scope=images.write
No /authorize step — no user, nothing to redirect. Auth server validates client_id/secret, checks scope allowed, mints token.
{
"access_token": "eyJhbGci...",
"token_type": "Bearer",
"expires_in": 3600
}
No id_token (no user, openid scope meaningless here). No refresh_token typically — client just calls /token again anytime with its permanent credentials.
Refresh Token — renewal, not login¶
POST https://auth.yourdomain.com/token
grant_type=refresh_token
&refresh_token=8xLOxBtZp8
&client_id=bff-client-123
&client_secret=super-secret-value
Auth server looks up the refresh token, checks not revoked/already used (if rotation on), issues new access_token (+ new id_token if openid scope, + new refresh_token if rotating).
Device Code — no browser on the device¶
POST https://auth.yourdomain.com/device_authorization
client_id=tv-app-789
&scope=openid profile
Response:
{
"device_code": "GmRhmhcxhwAzkoEqiMEg_DnyEysNkuNhszIySk9eS",
"user_code": "WDJB-MJHT",
"verification_uri": "https://auth.yourdomain.com/device",
"expires_in": 900,
"interval": 5
}
Device shows user_code + verification_uri; user completes login on a separate device. Original device polls:
POST /token
grant_type=urn:ietf:params:oauth:grant-type:device_code
&device_code=GmRhmhcxhwAzkoEqiMEg...
&client_id=tv-app-789
Returns authorization_pending until approved, then tokens.
Implicit Flow — deprecated¶
GET /authorize?response_type=token&client_id=...&redirect_uri=...
→ redirects to: https://app.yourdomain.com/callback#access_token=eyJhbGci...&token_type=Bearer
Tokens returned directly in the redirect's URL fragment, no /token step, no code, no secret. Deprecated — fragment leaks via browser history, referrer headers, extensions. Superseded by PKCE-secured Auth Code flow. Know it existed; don't build on it.
OIDC — Identity Layer on Top of OAuth2¶
OAuth2 answers "can this client access this resource" — access_token is opaque by spec, no mandated content.
OIDC answers "who is this user" — adds exactly three things.
1. The openid scope¶
Presence of openid in the requested scope tells the auth server: mint an id_token too, not just an access_token. profile and email are additional standard scopes controlling which claims get bundled (profile → name, given_name, picture; email → email, email_verified).
2. The id_token¶
Always a JWT, mandated claim structure (unlike access_token, which is opaque by spec even if implemented as a JWT).
{
"iss": "https://auth.yourdomain.com",
"sub": "user-8821",
"aud": "bff-client-123",
"exp": 1751980800,
"iat": 1751977200,
"auth_time": 1751977195,
"nonce": "n-0S6_WzA2Mj",
"name": "Abhishek Mishra",
"email": "abhishek0023c@gmail.com"
}
Strictly for the client. Read once, used to establish identity/session (e.g. SignInAsync in a BFF), then discarded. Never sent to a resource server or API.
Why it can never go to an API:
- aud names the client, not the API — a correctly built resource server rejects on audience mismatch
- Carries identity data (who), not authorization data (what you're permitted to do) — that's access_token's job
3. The /userinfo endpoint¶
GET https://auth.yourdomain.com/userinfo
Authorization: Bearer <access_token>
Authenticated with access_token, not id_token. Live lookup for additional claims about the sub — useful since profile data may have changed since the id_token was minted (a frozen snapshot).
JWT Claims — Full Reference¶
Registered claims (RFC 7519)¶
| Claim | Meaning | Notes |
|---|---|---|
iss |
Issuer | Verifier checks this matches the trusted auth server |
sub |
Subject | Stable unique user ID — never email/username (those can change) |
aud |
Audience | Who the token is for; receiver checks its own identity is listed. Can be an array. |
exp |
Expiration | Unix timestamp, hard cutoff |
nbf |
Not before | Token invalid before this instant — rarely used |
iat |
Issued at | When this token was minted — updates on every refresh |
jti |
JWT ID | Unique per-token identifier, used for fine-grained replay detection |
OIDC-only claims (id_token)¶
| Claim | Meaning | Notes |
|---|---|---|
auth_time |
When the user actually authenticated | Distinct from iat — stays frozen across silent refreshes, unlike iat |
nonce |
Client-generated, echoed into id_token | Defends against replay of a stolen id_token from an unrelated session |
acr |
Authentication strength (e.g. password vs MFA) | Relying party can demand a minimum level |
amr |
Array of methods actually used, e.g. ["pwd","otp"] |
Useful for audit/step-up auth |
azp |
Authorized party | Disambiguates trust when aud has multiple values |
Profile/email claims (data only, no security function)¶
name, given_name, family_name, picture, email, email_verified, phone_number, phone_number_verified
JWT header¶
{
"alg": "RS256",
"typ": "JWT",
"kid": "2024-key-01"
}
alg— signing algorithm.RS256= RSA/SHA-256, asymmetric — sign with private key, verify with public key, enables stateless gateway validation via JWKS.HS256= symmetric, same secret signs and verifies — unsuitable when verifier ≠ issuer.typ— "this is a JWT"kid— key ID, tells verifier which public key from JWKS to use (enables rotation)
Custom/private claims¶
Anything not reserved — issuer's choice. Example: role, tenant_id, permissions. These are what a gateway extracts and forwards as headers for service-level authorization decisions.
iat vs auth_time vs nonce — Detailed¶
iat (issued at): Unix timestamp of when this specific token was minted. Updates every time a new token is created, including via refresh. Used for clock-skew tolerance (reject if iat is in the future beyond a small tolerance) and to distinguish a fresh token from a refreshed one.
auth_time: When the user actually typed their credentials. Frozen at the original login moment — does not update on refresh. Distinction matters: someone silently refreshing tokens for days has an ever-updating iat but a static auth_time.
nonce: Random value client generates, sent in /authorize, must be echoed back inside the resulting id_token. Client checks match. Defends against replay of a previously-issued, otherwise perfectly valid id_token from a different session — signature checks out, exp/aud/iss all fine, but the nonce won't match this specific flow's value. Contrast with state, which defends the /authorize → redirect step against CSRF; nonce defends the id_token artifact itself against replay.
Access Token: Opaque vs JWT¶
"Opaque" in OAuth2 spec means opaque to the client only — the client forwards it verbatim, never parses it. The resource server's approach differs by implementation choice made once by the auth server operator.
Option A — JWT access_token (self-contained)¶
Flow at the resource server / gateway:
1. Split JWT on . — header, payload, signature
2. Decode header, read kid
3. Fetch (cached) JWKS, find matching key by kid
4. Verify signature over header.payload using that public key
5. Check exp, nbf, aud (includes this API), iss (trusted issuer)
6. Read claims (sub, role, permissions) directly for authorization
Zero network call to the auth server at request time — pure local cryptographic verification against a cached public key.
Option B — Opaque access_token (introspection)¶
POST https://auth.yourdomain.com/introspect
token=8xLOxBtZp8kkYcxSAhQe
&client_id=resource-server-id
&client_secret=resource-server-secret
(Resource server authenticates itself here — introspection responses can carry sensitive data.)
Auth server does a store lookup, not cryptography:
SELECT active, sub, scope, exp, client_id
FROM issued_tokens
WHERE token_hash = SHA256('8xLOxBtZp8kkYcxSAhQe')
Response (RFC 7662):
{
"active": true,
"sub": "user-8821",
"scope": "invoices.read",
"client_id": "bff-client-123",
"exp": 1751978100
}
Or if invalid/expired/revoked: {"active": false} — no other fields.
Live network call to the auth server on every request.
Tradeoff¶
| JWT | Opaque + introspection | |
|---|---|---|
| Validation | local, no network call | network call every request |
| Revocation | hard — valid until exp, no early kill |
instant — flip active to false |
| Scale | every service caches JWKS independently | auth server load grows with total request volume |
JWKS — Full Detail¶
GET https://auth.yourdomain.com/.well-known/jwks.json
{
"keys": [
{
"kty": "RSA",
"kid": "2024-key-01",
"use": "sig",
"alg": "RS256",
"n": "0vx7agoebGcQSuuPiLJXZ...",
"e": "AQAB"
},
{
"kty": "RSA",
"kid": "2025-key-02",
"use": "sig",
"alg": "RS256",
"n": "yG7fzX9blMcR...",
"e": "AQAB"
}
]
}
Field meanings:
- kty — key type (RSA here; could be EC for elliptic curve)
- kid — key ID; matched against the kid in a JWT's header to select the right key
- n, e — RSA public key material (modulus, exponent) — the actual math plugged into signature verification
- use — sig (signature verification) vs enc (encryption)
Why an array, not a single key: key rotation without downtime. If only one key were published, swapping it would instantly invalidate every unexpired token signed with the old key. Instead: new tokens get signed with a new private key while the old public key stays listed in JWKS until all old-signed tokens naturally expire. Verifier picks the right key per-token via kid. Once the overlap window passes, the old key is removed from the array.
Consumer flow:
1. Fetch JWKS once, cache (refresh periodically, e.g. hourly, or on a kid cache-miss)
2. Token arrives, read kid from header
3. Match against cached array; on miss, refresh cache once (handles just-rotated case) before rejecting
4. Reconstruct RSA public key from n/e, verify signature
Discovery Document¶
GET https://auth.yourdomain.com/.well-known/openid-configuration
{
"issuer": "https://auth.yourdomain.com",
"authorization_endpoint": "https://auth.yourdomain.com/authorize",
"token_endpoint": "https://auth.yourdomain.com/token",
"introspection_endpoint": "https://auth.yourdomain.com/introspect",
"jwks_uri": "https://auth.yourdomain.com/.well-known/jwks.json",
"userinfo_endpoint": "https://auth.yourdomain.com/userinfo",
"device_authorization_endpoint": "https://auth.yourdomain.com/device_authorization",
"scopes_supported": ["openid", "profile", "email", "invoices.read", "invoices.write"],
"response_types_supported": ["code", "token", "id_token"],
"grant_types_supported": ["authorization_code", "client_credentials", "refresh_token", "urn:ietf:params:oauth:grant-type:device_code"],
"code_challenge_methods_supported": ["S256", "plain"],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"]
}
Single well-known file every OIDC-compliant auth server publishes. Lets a client or resource server auto-configure against the auth server without hardcoding every endpoint URL.
This is how you know JWT vs introspection, once, at integration time — not per-token at runtime:
- jwks_uri present and populated → this auth server's tokens (at least id_token, likely access_token too) are JWTs; implement local signature verification
- Only introspection_endpoint present, no usable JWTs for access_token → implement introspection calls instead
The resource server is built once against a known auth server whose format is a fixed, documented fact — never a runtime decision. The client is entirely agnostic to this choice; it just forwards whatever token string it received.
One-Line Summary¶
OAuth2 is about authorization (can you access this resource); OIDC adds authentication (who are you) via a standardized id_token, scoped by openid, retrievable/expandable via /userinfo, self-describing via the discovery document, and validated statelessly via JWKS.
If a token's job is "prove who logged in" — it's id_token, dies at the client. If a token's job is "prove this request is allowed" — it's access_token, travels to APIs.