Security
Part 2 of 7 · OAuth & OIDCOpenID Connect — ID Tokens, UserInfo, Discovery & Nonce
OpenID Connect is the identity layer on OAuth 2. The client asks for scope openid, validates an ID token aimed at itself (iss, aud, exp, nonce), and may call UserInfo. Discovery publishes the endpoints. Never send the ID token to your API as a bearer.
- 1Gist
- 2Maps
- 3Q&A
- 4Sandbox
Voice readout needs Web Speech Synthesis in this browser.
Use OIDC instead of a homegrown login JWT
Prefer
OIDC on the same authorize and token endpoints
Scope openid, an ID token for the client, UserInfo when the ID token stays small, and discovery so you are not hardcoding every URL.
- aud of the ID token is your client_id.
- nonce binds that token to this login.
- Standard IdPs, mobile SDKs, and federation already speak it.
- Access tokens stay the only bearer you send to APIs.
Alternative
A JWT you mint yourself and call login
You own every footgun: no discovery, no JWKS rotation story, no nonce, and a token that apps start forwarding to APIs.
- Interop stops at your SDK.
- Multi-IdP federation becomes a rewrite.
- Email-as-subject collides and changes.
- Hybrid response types that return tokens on the front channel are rarely worth it.
Nonce binds the ID token to this login
The access token takes a different road. It is the only token the API should see.
- 1
Store a random nonce
Put it in the login session before the redirect. Scope includes openid. - 2
Authorize, then redeem the code
Same Auth Code + PKCE dance as the hub. The token response includes an id_token. - 3
Check nonce, iss, aud, exp
Signature comes from the discovery JWKS. Mismatch means reject. - 4
Call APIs with the access token
The ID token stays on the client. UserInfo is optional profile, not AuthZ.
Overview
OpenID Connect is an identity layer on top of OAuth 2. The client requests scope openid, receives an ID token (always a JWT) that says who authenticated, optionally calls UserInfo, and finds the endpoints in {issuer}/.well-known/openid-configuration. OAuth alone does not standardize "who is the user" for the client.
The hub already walks Authorization Code + PKCE. This page is the identity half: which token is for whom, and the checks that make an ID token believable.
ID token versus access token
| ID token | Access token | |
|---|---|---|
| Audience | The client (aud is client_id) | The resource server |
| Purpose | Authenticate the user to the client | Authorize the API call |
| Format | Always a signed JWT | JWT or opaque |
| Send to APIs? | Never as a bearer | Yes |
| Claims you expect | sub, iss, aud, exp, iat, nonce, auth_time | scope or scp, aud |
Flow
- 1
Step 1 OP issues id_token and access_token
- nextStep 2 Client receives both
- 2
Step 2 Client receives both
- nextStep 3 Validate id_token - signature, iss, aud = client_id, exp, nonce
- nextStep 5 Call the API with the access_token only
- sends id_token as BearerFailure path - API rejects it, aud is the client
- 3
Step 3 Validate id_token - signature, iss, aud = client_id, exp, nonce
- nextStep 4 Start the client session for sub
- 4
Step 4 Start the client session for sub
- 5
Step 5 Call the API with the access_token only
- nextStep 6 API checks aud and scope
- 6
Step 6 API checks aud and scope
- 7
Failure path - API rejects it, aud is the client
Lesson map
OpenID Connect — ID Tokens, UserInfo, Discovery & Nonce
OpenID Connect is the identity layer on OAuth 2. The client asks for scope openid, validates an ID token aimed at itself (iss, aud, exp, nonce), and may call UserInfo. Discovery publishes the endpoints. Never send the ID token to your API as a bearer.
Architecture. Step 1 OP issues id_token and access_token Ready. Step 2 Client receives both Ready. Step 3 Validate id_token - signature, iss, aud = client_id, exp, nonce Ready. Step 4 Start the client session for sub Ready. Step 5 Call the API with the access_token only Ready. Step 6 API checks aud and scope Ready. Failure path - API rejects it, aud is the client Ready
Select a node to see why it exists, or an edge to see the protocol, direction, effect, and consequence.
Mermaid export
flowchart TB op["Step 1 OP issues id_token and access_token Ready"] c["Step 2 Client receives both Ready"] v["Step 3 Validate id_token - signature, iss, aud = client_id, exp, nonce Ready"] s["Step 4 Start the client session for sub Ready"] rs["Step 5 Call the API with the access_token only Ready"] az["Step 6 API checks aud and scope Ready"] f["Failure path - API rejects it, aud is the client Ready"] op -->|continues| c c -->|continues| v v -->|continues| s c -->|continues| rs rs -->|continues| az c -->|sends id_token as Bearer| f
Validations that matter
- Signature with the JWKS from discovery. Honor
kid. Algorithm allowlists live on the JWT page. issis the exact issuer you configured.audincludes thisclient_id(string or array).exp/iat/nbfwith a small clock skew, about a minute.nonceequals the value you sent on/authorize.azp(authorized party), when it is present alongside multiple audiences, should be yourclient_id.- Optional
auth_timewhen you sentmax_age, andacr/amrwhen you need step-up evidence. at_hashwhen the ID token and access token were issued together — it binds them. Libraries usually check this.
Without nonce, a client that accepts an ID token out of band can be replayed with an old token. state does not do this job. state binds the redirect; nonce is inside the ID token.
Sequence
- 1
Client → Client
store nonce in the login session
- 2
Client → OpenID Provider
authorize scope openid and nonce
- 3
OpenID Provider → Client
redirect with code
- 4
Client → OpenID Provider
token request
- 5
OpenID Provider → Client
id_token containing nonce
- 6
Client → Client
nonce matches or reject
Discovery
Providers publish JSON at {issuer}/.well-known/openid-configuration.
| Field | Why you read it |
|---|---|
issuer | Must match the issuer you pinned |
authorization_endpoint / token_endpoint | The OAuth dance |
userinfo_endpoint | Profile claims |
jwks_uri | Keys for ID token signatures |
id_token_signing_alg_values_supported | What you are willing to accept |
scopes_supported | openid plus profile and API scopes |
Cache the document. Pin HTTPS. If the issuer inside the document does not match the issuer you configured, treat it as issuer injection and refuse it. Do not follow an authorization server URL that came from an attacker.
UserInfo and standard claims
GET the UserInfo endpoint with the access token. The body is claims as JSON, or sometimes a JWT. Use it when the ID token was kept small, or when you need a fresher profile. UserInfo is not a substitute for access-token authorization on your resource server.
| Claim | Meaning |
|---|---|
sub | Stable user id at this issuer. Not an email. |
name, email, email_verified | Profile. Emails change and collide across IdPs. |
preferred_username | Not globally unique |
auth_time | When the user actually authenticated |
acr / amr | Context class and methods (MFA evidence) |
prompt=login forces a fresh authentication. max_age requires auth_time newer than N seconds. offline_access (or the vendor's equivalent) is how you ask for a refresh token — pair it with rotation. Social login is still this protocol: your app trusts an iss + sub pair. SAML is the older enterprise federation; OIDC is the JSON/OAuth-native one. Encrypted ID tokens (JWE) show up when the token crosses logs or an untrusted browser; most apps use signed JWS only. Hybrid flows that return tokens on the authorize redirect are rarely needed — prefer code + PKCE from the hub. Multi-tenant issuers often bake the tenant into iss. Allowlist issuers. Do not accept "any tenant that signed something."
Sandbox: structural ID token checks (Python)
Signature verification is a separate step (and a real library). This sandbox checks the claims you would still enforce after the signature is good.
Press Run. Snippets must be self-contained — no network, files, or native modules.
Same checks (TypeScript)
Press Run. Snippets must be self-contained — no network, files, or native modules.
Pitfalls
Draw the token response with id_token and access_token. Label each audience. Show the API rejecting the ID token. Then replay an old ID token whose nonce does not match the session you just created.
Interview Q&A
Can I pass the ID token to my API as a bearer?
Answer
No. The API expects an access token with its audience. The ID token's audience is the client that requested login.
Why is sub not an email?
Answer
Emails change and collide across providers. sub is an opaque stable identifier for that user at that issuer. Store the pair iss + sub.
What if aud is an array?
Answer
Still valid. Your client_id must be in it. When policy requires azp, that authorized party should be your client.
What does nonce stop that state does not?
Answer
state binds the browser redirect (login CSRF). nonce is copied into the ID token so a stolen or old ID token cannot be replayed into this client out of band.
What if the discovery document is wrong?
Answer
Pin the issuer, use TLS, and require the document's issuer to match. A swapped jwks_uri is how you start trusting the attacker's keys.
prompt=login versus max_age?
Answer
prompt=login forces the IdP to re-authenticate. max_age requires auth_time newer than N seconds so a stale SSO session is not enough.
When do you call UserInfo?
Answer
When the ID token was minimized, or you need fresher profile claims. It is not how your resource server decides authorization.
What is at_hash?
Answer
A binding between the ID token and the access token issued with it. If both arrived together, check it (libraries usually do) so the two tokens cannot be swapped independently.
How do multi-tenant issuers work?
Answer
The issuer string often includes the tenant. Allowlist the issuers you meant to trust. A valid signature from an unexpected issuer is still a reject.
Hybrid flow?
Answer
The authorize response carries a code and tokens. That puts tokens on the front channel. Prefer Authorization Code + PKCE from the hub.
How does OIDC relate to SAML?
Answer
Both federate identity. OIDC is JSON and OAuth-native. SAML is still common for enterprise SSO. Your app still wants a stable subject and an issuer allowlist either way.
What is offline_access?
Answer
The scope (or the vendor's name for it) that asks for a refresh token. Rotation and reuse detection are the refresh lesson, not a reason to keep a year-long refresh in the browser.
Go Deeper
- OpenID Connect Core 1.0
- OpenID Connect Discovery 1.0
- OAuth 2.0 Multiple Response Type Encoding Practices
- Auth0 — ID tokens
- Okta — OpenID Connect and OAuth 2.0
- jwt.io — inspect samples only. Never paste a production token.
- Next: JWT vs opaque tokens — JWKS and revocation