OAuth 2.0 (authorisation) plus a signed identity token = OIDC. Companion to Introduction to OAuth and OAuth for MCP.
openid scope flowOAuth 2.0 answers "may this app act on the user's behalf?" β it never says who the user is. Apps that wanted "sign in" had to abuse OAuth (e.g. fetch /me from Facebook and trust the result). OIDC turns OAuth's authorisation answer into a verifiable identity assertion.
sub, email, name, picture, β¦/.well-known/openid-configurationEvery "Sign in with Google / Microsoft / Apple / GitHub" button you've ever clicked is OIDC. The standard turned what was a "social login" hack in 2010 into the default identity protocol of the modern web.
auth_time)Using an OAuth access token as a login indicator. The OAuth deck (slide 15) has the full picture; this deck dives in to the consequences.
https://alice.myopenid.com as your identityOIDC is not a separate protocol β it's a profile of OAuth 2.0 plus a few small additions. Everything you know from the OAuth deck still applies; OIDC just changes what comes back.
GET /authorize?
response_type=code&
client_id=app123&
scope=invoices:read&
redirect_uri=https://app/cb&
state=...&code_challenge=...&code_challenge_method=S256
# Token response
{ "access_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 3600 }
GET /authorize?
response_type=code&
client_id=app123&
scope=openid profile email& # β magic word
redirect_uri=https://app/cb&
state=...&nonce=abc123& # β required by OIDC
code_challenge=...&code_challenge_method=S256
# Token response β now has an ID token
{ "access_token": "eyJ...",
"id_token": "eyJ...", # β the identity assertion
"token_type": "Bearer",
"expires_in": 3600 }
openid scope (mandatory)nonce on the request, validate it on the ID tokenid_token alongside access_token/userinfo with the access tokenAn OIDC-conformant flow must use Authorisation Code + PKCE + nonce. Implicit and hybrid response types still exist in the spec but are deprecated by FAPI and most modern profiles.
openidBrowser-visible. Carries the code only β no tokens.
Server-to-server, TLS. ID token comes here. PKCE verifier required.
UserInfo for fresh attributes; the ID token alone is enough for login.
An ID token is a signed JWT. Three dot-separated base64url parts: header, payload, signature. The OP signs; the RP verifies offline via JWKS.
# Header
{
"alg": "RS256",
"kid": "abc123",
"typ": "JWT"
}
# Payload β required claims
{
"iss": "https://accounts.google.com",
"sub": "1098765432109876543210",
"aud": ".apps.googleusercontent.com",
"iat": 1714000000,
"exp": 1714003600,
"nonce": "abc123"
}
| Claim | Meaning |
|---|---|
iss | Issuer URL β must match OP's metadata |
sub | Stable user-ID at this OP |
aud | Your client ID β exact match |
iat | Issued at (Unix seconds) |
exp | Expiry β typically 5β15 min |
nonce | Echoes the value you sent |
auth_time β when the user actually authenticatedacr β authentication context class (e.g. urn:mace:incommon:iap:silver)amr β authentication methods (["pwd","mfa","otp"])azp β authorised party (when token has multiple aud)at_hash β hash of access token, binds them togetherc_hash β hash of code (hybrid flow)email, email_verifiedname, given_name, family_name, preferred_usernamepicture, locale, zoneinfophone_number, phone_number_verifiedprofile, email, phone scopes β and only if the OP exposes themsub as the user IDNever use email. Email addresses can be transferred between people; sub is a stable opaque ID per OP. Composite key: (iss, sub).
Every step here has been the source of a real-world breach. Use a conformant library β jose, oidc-client-ts, openid-client, Microsoft.IdentityModel, Authlib. Don't write this yourself.
alg=noneHS256 if you only have a public key (alg-confusion CVE-2015-9235 family)kid in the OP's cached JWKSkid not found, refresh JWKS once and retryiss matches the OP discovery's issuer exactly (string-equal)aud contains your client_id (and only it)aud is multi-value, azp equals your client_idexp is in the future (with small clock skew, β€ 5 min)iat is not unreasonably in the futurenonce equals the value you sentauth_time, acr, amrkid: refetch once and retry; otherwise rejectimport { createRemoteJWKSet, jwtVerify } from 'jose';
const JWKS = createRemoteJWKSet(
new URL('https://accounts.google.com/.well-known/jwks.json')
);
const { payload } = await jwtVerify(idToken, JWKS, {
issuer: 'https://accounts.google.com',
audience: process.env.GOOGLE_CLIENT_ID,
clockTolerance: 5,
});
if (payload.nonce !== sessionNonce) throw new Error('nonce');
return { id: payload.sub, email: payload.email };
Access tokens are not signed for you, may be opaque, and aren't bound to an aud you control. Always validate the ID token for "who is this user".
/.well-known/openid-configurationDiscovery turns "where is the token endpoint?" from configuration into data. The RP fetches one JSON document from the OP at start-up; everything else falls out of it.
$ curl https://accounts.google.com/.well-known/openid-configuration
{
"issuer": "https://accounts.google.com",
"authorization_endpoint":
"https://accounts.google.com/o/oauth2/v2/auth",
"token_endpoint":
"https://oauth2.googleapis.com/token",
"userinfo_endpoint":
"https://openidconnect.googleapis.com/v1/userinfo",
"revocation_endpoint":
"https://oauth2.googleapis.com/revoke",
"jwks_uri":
"https://www.googleapis.com/oauth2/v3/certs",
"response_types_supported": ["code","token","id_token", ...],
"subject_types_supported": ["public"],
"id_token_signing_alg_values_supported": ["RS256"],
"scopes_supported": ["openid","email","profile"],
"claims_supported": ["aud","email", ... ]
}
iss in tokens against this exact issuer stringhttps://login.microsoftonline.com/{tenant}/v2.0/.well-known/...{tenant}.b2clogin.com/{tenant}/{user-flow}/v2.0/.well-known/...https://{tenant}.auth0.com/.well-known/...Common.com Live Microsoft tokens use a different issuer per tenant; if your code hard-codes "https://login.microsoftonline.com/common/v2.0" you will reject every real customer's token. Always resolve issuer from tid claim β tenant-specific discovery.
{
"keys": [
{
"kty": "RSA",
"use": "sig",
"alg": "RS256",
"kid": "1f7dβ¦",
"n": "vK⦠(modulus, base64url)",
"e": "AQAB"
},
{
"kty": "EC",
"use": "sig",
"alg": "ES256",
"kid": "5b9cβ¦",
"crv": "P-256",
"x": "β¦", "y": "β¦"
}
]
}
kidkid: refetch once, retry; cache resultjose, jwks-rsa, python-jose, Microsoft.IdentityModel)If your JWKS cache TTL > OP rotation cadence, you will silently fail to validate new tokens once the OP rotates. Real-world: many small services that "worked fine" for months break at the next rotation.
| Scope | Releases |
|---|---|
openid | Required marker β without it, no ID token |
profile | name, family_name, given_name, picture, locale, β¦ |
email | email, email_verified |
address | Postal address claim object |
phone | phone_number, phone_number_verified |
offline_access | Issue a refresh token alongside |
GET /userinfo
Authorization: Bearer eyJhbGciOiJSUzI1NiIs...
200 OK
{
"sub": "1098765432109876543210",
"email": "alice@example.com",
"email_verified": true,
"name": "Alice Example",
"given_name": "Alice",
"family_name": "Example",
"picture": "https://lh3...",
"locale": "en"
}
id_token)/userinfoFor very large or sensitive attributes (medical, government), OIDC supports claims that point to a different endpoint with their own access tokens β rarely seen in practice, but the spec exists.
OIDC inherits OAuth's response_type and adds three more. Most are now deprecated; one is the modern default.
response_type | Returns from /authorize | Status (2025) |
|---|---|---|
code | code | Recommended β Authorisation Code + PKCE |
id_token | id_token (no access token) | Deprecated β implicit, no PKCE |
id_token token | id_token + access_token | Deprecated β full implicit |
code id_token | code + id_token (hybrid) | Niche β front-channel ID + back-channel access |
code token | code + access_token (hybrid) | Avoid |
code id_token token | all three | Avoid |
code id_token) ever existedresponse_type=code + PKCE + nonce + Authorisation Code Flow. Same as OAuth 2.1's only-allowed flow. Everything else is a legacy compatibility lever.
Login is the easy part. Logging the user out everywhere is the hard one β OIDC has three mechanisms, none of them perfect.
end_session_endpointid_token_hint + post_logout_redirect_urifrontchannel_logout_uriTruly synchronous SLO across N apps is hard. The pragmatic stance: back-channel + short ID-token TTLs + refresh-token revocation. Accept eventual consistency for tabs already open.
Front-channel logout, the OP's check_session_iframe, and many SSO mechanisms relied on third-party cookies. Chrome's deprecation timeline (2024β2026) is forcing migration to back-channel logout and FedCM-style browser APIs.
The user is at one device (a kiosk, a call-centre agent's screen) and approves on another (their phone). No browser redirect. FAPI's flow of choice for high-assurance approvals.
/bc-authorize with a login_hint identifying the userauth_req_id and a polling/notify mode/token with the auth_req_id (or receives a webhook)The 2020s rewrite of "decentralised identity". SIOPv2 (Self-Issued OpenID Provider v2) lets a wallet on the user's device act as its own OP. The RP gets an ID token signed by the wallet, plus optional Verifiable Credentials issued by trusted authorities.
If you're building a generic web app, SIOPv2 is on the horizon, not the requirement. If you're integrating with EU public services from 2026, or healthcare / banking from a regulated authority, it will land on your roadmap. Watch Sphereon, walt.id, Mattr, Fido Alliance for SDKs.
How do thousands of OPs and RPs trust each other without manual key exchange? OpenID Federation 1.0 β finalised Sep 2024 after a long draft phase β answers that with a tree of signed metadata.
/.well-known/openid-federationOpenID Federation is not yet a daily-driver concern. If your stakeholders are governments, financial regulators, or large research consortia, start tracking it; otherwise, the classic OIDC discovery + per-RP registration is still the standard.
FAPI (Financial-grade API) is an OpenID Foundation security profile. It pins down which OAuth/OIDC parameters and algorithms are mandatory for high-value APIs. FAPI 2.0 Security Profile reached Implementer's Draft 2 in 2024 and is the basis for Open Banking 2.0+.
FAPI conformance is testable. Customers in regulated sectors will run the OpenID Foundation conformance suite against your OP. Adding "FAPI-ready" to your marketing without certification is a fast way to lose a deal.
| Property | SAML 2.0 | OIDC 1.0 |
|---|---|---|
| Year | 2005 | 2014 |
| Encoding | XML | JSON / JWT |
| Native to | Browser only | Browser, mobile, M2M |
| Mobile / SPA support | Painful | First-class |
| Signature format | XML-DSig (notoriously fiddly) | JWS (clean, well-tested libraries) |
| Discovery | SAML metadata XML | /.well-known/openid-configuration |
| Federation | eduGAIN, InCommon (mature) | OpenID Federation 1.0 (just shipped, 2024) |
| Logout | SLO, SP-initiated, IdP-initiated | RP-initiated, back-channel, front-channel |
| Enterprise tooling | Decades of vendor support | Increasingly the default for new vendors |
| "It just works" libraries | Few; high TCO | Many; low TCO |
Practical answer for B2B SaaS: support both. WorkOS / Auth0 / Okta normalise SAML and OIDC behind one API.
| OP | Issuer URL | Quirks worth knowing |
|---|---|---|
https://accounts.google.com |
Issues email_verified; rotates JWKS daily; offline access requires access_type=offline&prompt=consent |
|
| Microsoft Entra | https://login.microsoftonline.com/{tid}/v2.0 |
Tenant-scoped issuer; v1 vs v2 endpoints; tid claim identifies tenant; "common" endpoint exists but resolves per-token |
| Apple Sign in with Apple | https://appleid.apple.com |
Returns email only on first login; relays via private email; email_verified is "true" but Apple-private |
| GitHub | OAuth 2.0; no real OIDC; UserInfo-equivalent at /user |
Often retrofitted as OIDC by libs (e.g. NextAuth); not actually conformant |
| Okta / Auth0 | https://{tenant}.okta.com / {tenant}.auth0.com |
Per-tenant; very FAPI/OIDC-conformant; some custom claim namespaces |
| Keycloak | https://kc/realms/{realm} |
OSS; FAPI plugin; manual key rotation by default |
| ZITADEL | per-tenant URL | OSS; multi-tenant SaaS; full OIDC + Federation 1.0 roadmap |
| AWS Cognito | https://cognito-idp.{region}.amazonaws.com/{poolId} |
Cheap; UX has rough edges; OIDC discovery only on user pools, not identity pools |
The full Auth-as-a-Service provider tour, with self-hosted options and the OAuth specification trail, is in the Introduction to OAuth and OAuth for MCP decks.
Beyond user login, OIDC has quietly become the way workloads authenticate. GitHub Actions, GitLab CI, Buildkite, CircleCI, K8s clusters all sign OIDC tokens; the cloud trusts those tokens via configured issuer URLs. No long-lived static keys.
# GitHub action workflow:
permissions:
id-token: write # request OIDC token
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/deploy
aws-region: eu-west-2
# β role assumed via STS using OIDC; creds last 1h
- run: aws s3 cp ./dist s3://my-bucket --recursive
{
"Effect":"Allow",
"Principal":{"Federated":
"arn:aws:iam::123:oidc-provider/token.actions.githubusercontent.com"},
"Action":"sts:AssumeRoleWithWebIdentity",
"Condition":{
"StringEquals":{
"token.actions.githubusercontent.com:aud":"sts.amazonaws.com",
"token.actions.githubusercontent.com:sub":
"repo:acme/api:ref:refs/heads/main"
}
}
}
Trust the issuer alone gives access to every workload at GitHub. Always condition on repo:, ref:, and ideally environment:. Misconfigurations here have given external repos access to production AWS accounts.
email as identityTwo users could have the same verified email at different OPs. Always key on (iss, sub); treat email as a hint.
Provider rotates infra; JWKS URL changes. Resolve via discovery, every time, with a sane cache TTL.
nonce validationReplay attack: an attacker reuses a captured ID token for a different login attempt. Generate a fresh nonce per request, store in session, compare on return.
email_verified=falseSome OPs return unverified email addresses. Reject them, or treat them as "anonymous, please verify in-app".
ID tokens often contain email and name. Log only the JTI / sub, never the raw token; strip claims from telemetry.
alg confusionIf your library accepts whatever alg the JWT header asks for, an attacker can swap RS256 for HS256 and sign with the public key. Pin allowed algs.
Trusting https://login.microsoftonline.com/common/v2.0 trusts every Microsoft tenant. Always resolve to the specific tenant's issuer using the tid claim, then validate.
ID tokens should be 5β15 minutes. Re-derive sessions from the access/refresh-token pair, not from the original ID token, after the first hop.
openid scope + an ID token. Same flow you already know; one extra signed assertion comes back."OAuth answers 'may this app act for the user'; OIDC answers 'who is the user' β and almost every login button you've clicked in the last decade is the second of those, riding on the first."
OpenID Connect Core 1.0 Β· Discovery 1.0 Β· Dynamic Client Registration 1.0 Β· Session Management Β· RP-Initiated Logout Β· Back-Channel Logout Β· CIBA Core Β· SIOPv2 Β· OID4VCI / OID4VP Β· OpenID Federation 1.0 Β· FAPI 2.0 Security Profile Β· RFC 7519 (JWT) Β· RFC 7515/16/17/18 (JWS/JWE/JWA/JWK) Β· RFC 9068 (JWT Profile for Access Tokens) Β· RFC 9126 (PAR) Β· RFC 9449 (DPoP) Β· openid.net