OpenID Connect
OAuth plus an identity layer: an ID token issued to your client, signed by the provider, containing verified claims about who the user is — which is the piece OAuth deliberately does not provide.
Frame the problem
Security starts with a concrete asset, attacker capability and trust crossing.
What the identity layer adds
OAuth gets you an access token for an API. OIDC adds three things on top, and each one closes a specific gap that made "log in with OAuth" unsafe.
An ID token: a JWT issued *to your client*, with your client id in aud, signed by the provider, containing sub (a stable identifier for the user at this provider), iss, exp, and the nonce from your authorization request. Because it is addressed to you and bound to your request, a token obtained by a different application is not usable at yours — which is exactly the flaw in authenticating with a bare access token.
A UserInfo endpoint for claims that do not belong in the token, and standard scopes (openid, profile, email) with defined claim names, so integrating a second provider does not mean learning a second data model.
And discovery: a well-known configuration document naming the endpoints and the JWKS URI, so key rotation is automatic and clients do not hardcode certificates.
| OAuth 2.x | OpenID Connect | |
|---|---|---|
| Question answered | May this app act on the user's behalf? | Who is this user? |
| Primary artefact | Access token | ID token (plus an access token if needed) |
| Audience | The resource server | Your client, specifically |
| Verifiable by you? | No — it is not addressed to you | Yes — signature, iss, aud, nonce |
| Identity claims | None defined by the spec | sub, email, name, … with standard names |
| Correct use | Calling a third-party API | Logging a user into your application |
Verifying an ID token, and the identifier to key on
The verification is the JWT checklist from JWT Failure Modes with two additions specific to OIDC: aud must be your client id, and nonce must match the value you generated for this authorization request. The nonce is what prevents a previously-obtained ID token from being replayed into a fresh login.
The subtler decision is which claim identifies the user in your database. The answer is iss + sub together, not email. Email addresses change, can be reassigned by an organisation after an employee leaves, and — critically — may be unverified. A provider that returns email without email_verified: true is telling you the user typed it, not that they control it. Keying accounts on an unverified email is an account-takeover primitive: register at the identity provider with someone else's address, log in to the relying application, and inherit their account.
The related trap is account linking. When a user who signed up with a password later logs in with a provider that reports the same email, linking the two automatically is convenient and unsafe unless the email is verified by both sides. The safe pattern is to require the user to authenticate with the existing method before linking, or to require a verified email on both sides and log the link as a security event.
1const { payload } = await jwtVerify(idToken, jwksForIssuer(ISSUER), {2 algorithms: ['RS256'],3 issuer: ISSUER, // exactly the configured issuer4 audience: CLIENT_ID, // this token was minted FOR US5 maxTokenAge: '5m', // id_tokens are consumed immediately at login6})7if (payload.nonce !== saved.nonce) throw new AuthError('nonce mismatch') // replay defence8 9// Key on issuer + subject. Never on email alone.10let account = await accounts.byFederatedId(payload.iss, payload.sub)11 12if (!account) {13 // Linking to an existing local account requires a verified address AND,14 // for anything sensitive, proof that the user controls that account already.15 if (payload.email && payload.email_verified === true) {16 const existing = await accounts.byEmail(payload.email)17 if (existing) {18 if (!(await proveControlOfExistingAccount(existing))) throw new LinkRequiresVerification()19 await accounts.link(existing.id, payload.iss, payload.sub)20 await audit.log('account.federated.linked', { accountId: existing.id, iss: payload.iss })21 account = existing22 }23 }24 account ??= await accounts.createFederated(payload)25}26return establishSession(account.id)Federation and its consequences
Adopting OIDC means outsourcing authentication, and the trade should be explicit. What you gain: MFA, breached-password detection, device management, anomaly detection and session policy, all maintained by someone whose job it is — usually far better than you would build. What you take on: a hard dependency on the provider's availability, and a trust relationship where the provider can assert *any* identity in its namespace.
That second point has an operational shape in enterprise SSO. The identity provider administrator can, by definition, sign in as any user in the organisation. That is the intended design, and it means the provider's admin accounts are as privileged as your most privileged user. It also means deprovisioning must actually work: an employee disabled in the identity provider still holds any session your application issued, and any API token they created, until you revoke them. Provisioning protocols and back-channel logout exist for this; if you do not implement one, write down that offboarding is asynchronous and how long the window is.
Finally, plan for provider unavailability. Login is the front door; if the provider is down, nobody gets in. Whether you keep a break-glass local-credential path for administrators is a real decision with real risk on both sides — an emergency account is also a permanent attack target — and it should be made deliberately, protected with a hardware factor, and audited on every use.
Key points
- OIDC adds an ID token addressed to your client, which is what makes federated login verifiable.
- Verify signature,
iss,aud= your client id,exp, andnonceagainst the value you generated. - Key accounts on
iss+sub, never on email — and never link accounts on an unverified address. - Federation outsources authentication quality and takes on a hard availability and trust dependency.
- Deprovisioning is not automatic: sessions and API tokens outlive the disabled identity-provider account unless you revoke them.
Boundary control exercise
This lesson uses the shared boundary-control exercise.
Follow the attack
Safe conceptual simulation: capability → missing control → crossed boundary → asset impact.
- 1Attacker → register at a provider using a victim's email address, where the provider does not verify it.
- 2Unverified email → log in to a relying application that keys accounts on email.
- 3Account match → inherit the victim's account without ever touching their credential.
- 4Alternatively → replay an ID token obtained elsewhere at a client that does not check `aud` or `nonce`.
- Account takeover through email-based linking, with no credential compromise anywhere.
- Cross-client token replay where audience is unchecked.
- Continued access after offboarding, for as long as sessions and tokens survive.
Defend, detect, recover
One prevention is a single point of security failure. Layer it and make failure observable.
- • Require `email_verified` before any email-based linking, and require proof of control of the existing account.
- • Verify `aud` and `nonce` on every ID token; use a maintained OIDC library.
- • Implement back-channel logout or directory-driven deprovisioning, and revoke sessions and API tokens on disable.
- • Treat identity-provider administrators as top-tier privileged accounts with hardware-key MFA.
- • Alert on account linking events, especially where the local account predates the federated identity.
- • Alert on ID tokens rejected for audience or nonce mismatch.
- • Alert on successful authentication for an identity that was disabled in the directory.
- • Unlink the fraudulent federated identity and revoke all sessions for the affected account.
- • Audit all links created by the same provider identity or email address.
- • Force re-verification of the account owner through an independent channel.
- • The identity provider can assert any identity in its namespace; that trust is the design.
- • Provider outage is a total login outage unless a break-glass path exists — and that path is itself a target.
- • Deprovisioning lag is real in every implementation; the question is only how long.