Appearance
8.4.4 — OpenID Connect
A social app implements "Log in with Facebook" like this: the mobile client obtains an access token, sends it to the backend, and the backend calls Facebook's API with it to fetch the user's id. That id becomes the logged-in user.
It works. It is also broken, and the break is elegant.
An access token does not say who requested it. An attacker builds an unrelated app, gets users to sign in with Facebook there, and collects perfectly valid access tokens for those users. Sending one of those tokens to the social app's backend logs the attacker in as that user — the backend asks Facebook "who does this token belong to", gets the victim's id, and believes it.
This was a real and widespread class of vulnerability. OpenID Connect fixes it by issuing a token that names its intended audience and is signed for that audience specifically.
1. What OIDC adds
OpenID Connect (2014) is a thin identity layer on top of OAuth 2.0. It changes almost nothing about the flow and adds four things:
- An ID token= — a JWT containing identity claims, signed by the provider, addressed to a specific client.
- A UserInfo endpoint — an API returning claims about the user, called with the access token.
- Standard scopes and claims, so
emailmeans the same thing everywhere. - Discovery and key publication, so integrating a new provider is configuration rather than code.
You opt in by adding openid to the scope. That single value turns an OAuth request into an OIDC request and makes the provider return an ID token.
The vocabulary shifts slightly: the authorization server is now also the OpenID Provider (OP), and the client is the Relying Party (RP).
2. The ID token, and the rule that follows from it
json
{
"iss": "https://accounts.example.com", // (1)
"sub": "248289761001", // (2)
"aud": "sched-app", // (3)
"exp": 1754134800,
"iat": 1754131200,
"nonce": "n-0S6_WzA2Mj", // (4)
"auth_time": 1754131190, // (5)
"email": "ana@example.com",
"email_verified": true, // (6)
"name": "Ana Ruiz"
}(1) Who issued it. (2) The subject — stable, and unique only within this issuer. (3) The audience: this token is for sched-app and nobody else. This is the field that closes the attack in the opening. (4) Binds the token to your authorization request. (5) When the user actually authenticated, as opposed to when the token was issued — which is how you enforce "re-authenticate for this sensitive action". (6) Whether the provider verified the address, and section 6 is why that matters more than it looks.
Now the three-token rule, which resolves most confusion in this area:
| Token | Audience | Purpose |
|---|---|---|
| ID token | The client | Proves who logged in, and when |
| Access token | The resource server | Grants access to an API |
| Refresh token | The authorization server | Obtains new access tokens |
An ID token is for your application to read once at login. It is not an API credential. Sending an ID token to an API as a bearer token is a common shortcut and is wrong: its audience is the client, so a correctly implemented API must reject it — and an API that accepts it is accepting a token minted for a different party.
And the mirror mistake: an access token is not proof of identity. That is the opening vulnerability. If you need to know who the user is, read the ID token or call UserInfo with a token you obtained yourself.
3. The flow, and validating the ID token
The flow is the authorization code flow of Chapter 8.4.3 with scope=openid and an extra nonce:
GET /authorize?response_type=code&client_id=sched-app
&redirect_uri=https://sched.example.com/callback
&scope=openid%20profile%20email
&state=…&nonce=…&code_challenge=…&code_challenge_method=S256The token response now carries three things:
json
{ "access_token": "…", "id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6…",
"refresh_token": "…", "token_type": "Bearer", "expires_in": 3600 }Validating the ID token is seven checks, and skipping any of them is a real vulnerability:
- Signature — verify with the provider's public key from its JWKS endpoint, using the
kidin the header to select the right key. - Algorithm — pinned at the verifier, never read from the token (Chapter 8.4.2's confusion attack).
iss— exactly the expected issuer string.aud— contains your client id. This is the check that stops the opening attack.exp— not expired, with only seconds of clock tolerance.nonce— matches the value you sent and stored in the session.azp— if present and there are multiple audiences, it must be your client id.
ts
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.example.com', // (1)
audience: 'sched-app', // (2)
algorithms: ['RS256'], // (3)
clockTolerance: 30,
});
if (payload.nonce !== session.nonce) throw new Error('nonce mismatch'); // (4)(1)–(3) are what a certified library does when configured; the failure mode is code that decodes without verifying, which some libraries make dangerously easy — jwt.decode reads claims and checks nothing. (4) The nonce check is separate and is the one most often omitted, because libraries cannot do it: only your session knows what you sent.
state and nonce are different and both are needed. state protects the callback against CSRF — it is about the redirect. nonce binds the ID token to your authorization request, so a token captured from another flow cannot be replayed into yours.
4. Discovery and keys
Every OIDC provider publishes its configuration at a fixed path:
GET https://accounts.example.com/.well-known/openid-configurationjson
{
"issuer": "https://accounts.example.com",
"authorization_endpoint": ".../authorize",
"token_endpoint": ".../token",
"userinfo_endpoint": ".../userinfo",
"jwks_uri": ".../jwks.json",
"end_session_endpoint": ".../logout",
"scopes_supported": ["openid","profile","email"],
"id_token_signing_alg_values_supported": ["RS256","ES256"],
"code_challenge_methods_supported": ["S256"]
}This is why integrating a new provider is a configuration change. Give a library an issuer URL, a client id and a secret, and it finds everything else.
JWKS is the key set — the provider's public signing keys, each with a kid. Because signing is asymmetric (Chapter 8.2.3), you hold nothing secret to verify tokens.
Key rotation is the operational detail that causes outages. Providers rotate signing keys, publishing the new one before using it and keeping the old one until its tokens have expired. Your client must cache JWKS and refetch when it sees an unknown kid — with rate limiting, or an unknown kid on every request becomes an accidental denial of service against the provider. Caching the key set forever is the failure that produces "all logins broke overnight and nothing changed".
5. Claims: in the token or from UserInfo
Standard scopes map to claim sets: profile (name, picture, locale, updated_at), email (email, email_verified), address, phone.
Providers may return claims in the ID token, from the UserInfo endpoint, or both. The trade is size against a round trip: an ID token travels in URLs and headers, so putting fifty claims in it produces a token that proxies reject (Chapter 9.9.2). Keep the ID token small — identity and nothing else — and fetch profile detail from UserInfo when you need it.
UserInfo is called with the access token, not the ID token, and it must be called over the back channel.
Claims can go stale. An ID token is a snapshot from login time. A user who changes their name or loses a group membership still carries the old claims until the token is renewed — which is the same staleness problem as Chapter 8.4.2, now applied to authorisation data. Do not put fast-changing permissions in an ID token.
6. Account linking, and the mistake that hands over accounts
Identify users by iss + sub, never by email address. Three reasons, and the third is a live vulnerability:
sub is unique only within an issuer. Two providers can legitimately use the same subject value.
Email addresses change. A user updating their address at the provider must not become a new user in your system.
And an unverified email is an account takeover. Suppose you match on email. An attacker registers victim@example.com at a provider that does not verify addresses, signs in to your application, and is matched to the victim's existing account.
So the rules are:
- Store
(issuer, subject)as the identity, with a foreign key to your user record. - Only ever consider
email_verified: truefor matching, and even then treat automatic linking as a decision rather than a default. - When an existing account matches by email, require proof — ask the user to sign in with the original method first, then link. That one step blocks the whole attack class.
- Allow several identities per user, because people sign in with Google on their laptop and Apple on their phone and expect one account.
7. Logout
RP-initiated logout: redirect the user to the provider's end_session_endpoint:
GET /logout?id_token_hint=eyJ…&post_logout_redirect_uri=https://sched.example.com/goodbyeid_token_hint tells the provider which session to end and lets it skip a confirmation prompt. post_logout_redirect_uri must be pre-registered, for the same reason as redirect_uri.
Clearing your own session is not enough, and this surprises users constantly: log out of your app without ending the provider session, click login again, and you are instantly signed back in with no prompt — because the provider still has a session.
Back-channel logout (Chapter 8.4.3) is the reliable direction: the provider calls each client's registered logout endpoint with a signed logout token, which works without a browser and does not depend on third-party cookies.
A logout that must be global is genuinely hard, and worth being honest about: the provider's session, every relying party's session, and any access tokens already issued all have independent lifetimes.
8. Practical guidance
Use a certified library. The OpenID Foundation certifies implementations against a conformance suite. Hand-written validation misses one of the seven checks — usually nonce or aud — and the failure is silent.
Use the authorization code flow with PKCE. The hybrid flows exist for legacy reasons and complicate validation; implicit is dead.
Never call decode where you meant verify. It is a one-word difference that removes all security, and it appears in production code regularly.
Prefer the provider's session for authentication and your own session for authorisation. After a successful OIDC login, create your own server-side session (Chapter 8.4.2) and use it for the application. The ID token proved who arrived; it should not be the thing your application checks on every request.
Know what you are buying. OIDC gives you a login you do not have to secure, and it makes the provider a dependency: their outage is your outage, their account recovery is your account recovery, and a user who loses access to that provider loses access to you. Offer more than one method, and consider whether an account should be able to add a password later.
What the interviewer will push on
"What is the difference between OAuth and OIDC?" OAuth is authorization — an access token grants access to an API and says nothing about identity. OIDC adds an ID token with an audience, so a client can prove who logged in. The tell is describing the attack OIDC prevents: an access token obtained by any application can be replayed to yours if you treat it as identity.
"What is the difference between an ID token and an access token?" Audience and purpose: the ID token is for the client and is read once at login; the access token is for the API. Then state both mistakes — sending an ID token to an API (it must be rejected) and using an access token as proof of identity (the opening vulnerability).
"How do you validate an ID token?" Signature via JWKS using kid, pinned algorithm, iss, aud, exp, and nonce against the session. Mentioning that the nonce check is yours and not the library's is what distinguishes someone who has implemented it.
"What is the difference between state and nonce?" state protects the redirect against CSRF; nonce binds the ID token to your authorization request so a token from another flow cannot be replayed. Both are needed and they defend different steps.
"How do you match a returning user?" By (issuer, subject), never by email. Then the attack: matching on an unverified email lets an attacker register the victim's address at a lax provider and take over the existing account. Require proof before linking an existing account.
"A user logs out and is immediately logged back in. Why?" Your session was cleared and the provider's was not, so the next authorization request completes silently. The fix is RP-initiated logout with id_token_hint, and back-channel logout where a reliable global logout is required.
One thing to volunteer: mention JWKS caching and rotation. Providers rotate signing keys, so a client must cache the key set and refetch on an unknown kid — with rate limiting. Caching forever produces "every login broke overnight and we changed nothing", and refetching per request turns your own traffic into a denial-of-service against your identity provider.
Recall
- OIDC is an identity layer on OAuth 2.0, enabled by adding
openidto the scope. It adds the ID token, the UserInfo endpoint, standard claims, and discovery. - An access token proves consent, not identity — accepting one as a login is the vulnerability OIDC exists to fix, because a token obtained by any application can be replayed.
- Three tokens, three audiences: ID token → the client, access token → the API, refresh token → the authorization server. Never send an ID token to an API, and never treat an access token as identity.
- Validate an ID token with seven checks: signature (JWKS via
kid), pinned algorithm,iss,aud,exp,nonce,azp. Thenoncecheck is yours, not the library's. stateprotects the redirect;noncebinds the ID token to your request. Both, always.- Discovery at
/.well-known/openid-configurationmakes a new provider a configuration change. Cache JWKS and refetch on an unknownkid, rate limited — cache forever and logins break at rotation. - Keep the ID token small; fetch profile detail from UserInfo with the access token. Claims are a login-time snapshot, so do not put fast-changing permissions in one.
- Identify users by
(iss, sub), never by email. Matching on an unverified email is an account takeover; require proof before linking an existing account, and allow several identities per user. - Clearing your own session does not log the user out — use RP-initiated logout with
id_token_hint, and back-channel logout for reliability. Use a certified library, and neverdecodewhere you meantverify.
Self-test: What exactly does the aud claim prevent? · Why is the nonce check not something a JWT library can do for you? · Which token do you send to UserInfo? · Why is email a dangerous account key? · What happens when a provider rotates its signing key and you cached JWKS? · Why does logout appear not to work?
Next: 8.4.5 covers the protocol OIDC replaced and enterprises still run everywhere — SAML, its XML assertions, its bindings, and how to debug a login that fails with no useful error.