backend / authentication / sso / 02_oidc_oauth2_flows.md

OIDC and OAuth 2.0 Flows

7 interview angles 8 min read source

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:

  1. App generates random code_verifier.
  2. App sends code_challenge = SHA256(code_verifier) (base64url) with code_challenge_method=S256 in step 3.
  3. IdP stores the code_challenge against the issued auth code.
  4. App sends code_verifier (original random value) on token exchange.
  5. IdP recomputes SHA256(code_verifier) and compares to stored code_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:

  1. Signature — using JWKS from <issuer>/.well-known/jwks.json (see 03_tokens_id_access_refresh.md).
  2. iss matches the configured OP.
  3. aud contains your client_id (and azp if present).
  4. exp > now.
  5. iat within reasonable window (clock skew tolerance).
  6. nonce matches 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 /introspect to 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 state and nonce?”state is CSRF protection (sent in authorize request, verified in callback). nonce is 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 aud includes 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.