The deep end of OpenID Connect: the validation edge cases, the high-security profiles, the federation specs that are actually being deployed in 2026, the wallet-era stack (EUDI / mDL), and the operational patterns for production OPs.
aud, azp, multi-audience tokensprivate_key_jwt · DPoP — beyond bearerclaims parameterprompt, max_age, acr_values, amrEvery OIDC implementer learns the eight basic checks (signature, iss, aud, exp, iat, nonce, azp, at_hash). The bugs that cost you money live between them.
nbf" errors at midnight DST changeovers and on freshly-spawned Lambda containers.kid-less tokens & key roll-overkid force the RP to try every key — a CPU-burn DoS surface.kid-less tokens; cache JWKS 5–60 min; refresh on unknown kid; alert if >1 % of tokens trigger a refresh.alg=none permanently rejected.alg and the expected key type; never let the token's header pick the verification key class.nonce binding/authorize, must be reflected in the ID token.nonce in a cookie and check it after redirect have a CSRF-on-callback problem if the cookie isn't SameSite=Lax.nonce to the user's session (not a global cookie), validate then delete.iss driftiss URLs. RPs that pin one issuer break for guest users.https://login.microsoftonline.com/{tid}/v2.0) and explicitly check tid.aud Wars — Multi-Audience & azpThe aud claim is permitted to be a string or an array. The azp (authorized party) claim is required iff there is more than one audience. Most bugs in this area come from RPs that don't treat aud as both.
aud MUST contain the RP's client_id.aud has more than one value, azp MUST be present and equal to the party who started this flow.aud has more than one value and the RP is not azp, the RP MUST NOT trust the token as identity for itself.{
"iss": "https://login.acme.com/",
"sub": "user_42",
"aud": "client_web_app",
"exp": 1800003600,
"iat": 1800000000,
"nonce":"a7c…",
"auth_time": 1800000000
}
{
"iss": "https://login.acme.com/",
"sub": "user_42",
"aud": ["client_web_app","client_billing_api"],
"azp": "client_web_app", // who started this
...
}
RP billing_api receives the multi-audience ID token above and decides: "my client_id is in aud, therefore this is my user logging in." No — azp says the user logged into web_app. Promoting that ID-token sub to a session in billing_api is identity hijacking via a sibling app.
azp and refuse if azp != my_client_id._claim_sources — they have their own iss and aud, validate independently."aud says where the token is welcome; azp says who actually walked through the door."
The sub claim is the single most consequential identifier in OIDC. The same physical user can intentionally appear with different subs to different RPs, depending on the OP's subject_type setting.
sub for that user across every RP at the OP. Easy to correlate; suitable inside one trust boundary.sub. A user looks like a different identifier to every RP. Required for unlinkability (eIDAS, Apple Sign-In).The OP computes sub = HMAC(seed, sector_id || local_user_id). The sector identifier groups RPs that share a sub namespace.
# RP advertises a sector via sector_identifier_uri
GET https://app.example/.well-known/oidc-sector-uris
[
"https://app.example/cb",
"https://eu.app.example/cb",
"https://api.app.example/exchange"
]
Migrating an OP from public to pairwise after launch — every existing user changes sub, every RP loses its account-binding. Plan a dual-write or a sub_legacy claim before you flip.
Apple issues pairwise subs by default; the user can also choose to hide their email, in which case Apple inserts a relay address. Different sub per-app, no cross-RP correlation possible.
Classical OIDC stuffs every authorisation parameter into the front-channel URL. PAR instead POSTs them to a back-channel endpoint up-front, gets back a one-time request_uri, and uses that on /authorize.
Combine PAR with JAR (RFC 9101) and the parameters in the /par body are themselves a signed/encrypted JWT (a request object). The OP knows the parameters came from the legitimate RP, not just via it. That's the FAPI 2.0 baseline.
# the request object — a JWS (often signed with RP's
# private_key_jwt key)
{
"iss": "client_web_app",
"aud": "https://op.example",
"response_type": "code",
"client_id": "client_web_app",
"redirect_uri": "https://app/cb",
"scope": "openid profile email",
"state": "X9d…",
"nonce": "a7c…",
"code_challenge": "e9k…",
"code_challenge_method": "S256",
"claims": { "id_token": { "acr": { "essential": true,
"values": ["urn:mfa"] } } }
}
# delivered either as
GET /authorize?request=eyJhbGc… (by-value)
# or referenced
GET /authorize?request_uri=https://rp/req/abc (by-reference)
# (PAR is the formal "by-reference" mechanism — preferred)
# instead of ?code=…&state=…
GET /cb?response=eyJhbGciOiJSUzI1NiI…
# decoded payload
{
"iss": "https://op.example",
"aud": "client_web_app",
"exp": 1800000300,
"code": "AUTH_CODE",
"state": "X9d…"
}
iss/state.| Threat | Mitigation |
|---|---|
| Tampered request params | JAR / PAR |
| Tampered response params | JARM |
| Stolen authorisation code | PKCE + sender-constrained tokens |
| Mix-up across multiple OPs | JARM (signed iss) + RFC 9207 iss param |
FAPI 2.0 (Final, 2024) is the OpenID Foundation's hardened profile of OAuth/OIDC, originally for Open Banking. It is now also the baseline for several health, government, and high-value B2B ecosystems. It comes in two parts.
private_key_jwt or tls_client_auth — no shared secrets.redirect_uri string match./par.FAPI is certified. The OpenID Foundation runs a public conformance suite at openid.net/certification. Many ecosystems require a passing certificate before you can register a production client. Don't roll your own.
The FAPI 2.0 cheat-line: PAR + PKCE + DPoP/mTLS + private_key_jwt + Resource Indicators. If your stack is missing any of those for a high-value API, you're not FAPI-grade.
private_key_jwt · DPoPThree different ways to bind a token (or the request that mints it) to a key only the legitimate party controls.
| Mechanism | What it protects | How it works | Operational cost |
|---|---|---|---|
private_key_jwt (RFC 7523) |
Client → AS authentication on /token, /par, /register |
Client signs a short JWT assertion with its private key; AS verifies via JWKS the client published. | Low — RP just hosts a JWKS. Replaces all shared client secrets. |
| tls_client_auth / mTLS (RFC 8705) | Client → AS authentication and sender-constrained access tokens | Mutual TLS handshake; AS records cert hash; RS rejects calls whose client cert hash ≠ token's cnf.x5t#S256. |
Higher — needs PKI, cert rotation, careful TLS termination (edge proxies must not strip client certs). |
| DPoP (RFC 9449) | Sender-constrained access tokens for any client (browser, mobile, CLI) | Per-request JWS proof signed by client-generated key; token's cnf.jkt binds to that key's hash. |
Low for clients, moderate for RS (must implement jti replay cache, htm/htu validation). |
private_key_jwt client assertionPOST /token HTTP/1.1
Host: op.example
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code&
code=AUTH&code_verifier=…&
client_id=client_web_app&
client_assertion_type=
urn:ietf:params:oauth:client-assertion-type:jwt-bearer&
client_assertion=eyJhbGc… ← the JWS
# decoded assertion
{ "iss":"client_web_app", "sub":"client_web_app",
"aud":"https://op.example/token",
"jti":"e1b7…", "iat":1800000000, "exp":1800000060 }
private_key_jwt + DPoP, or mTLS if PKI exists.Stateless reverse-proxies that terminate TLS and forward the request without surfacing the client cert in a header (X-SSL-Client-Cert or similar). The RS sees a different mTLS endpoint and the cert binding silently drops to nothing.
"Sign out" in OIDC is three different specs. They solve different problems and you usually need more than one.
GET /logout?
id_token_hint=eyJ…&
post_logout_redirect_uri=
https://app/bye&
state=…
post_logout_redirect_uri.Local-RP-driven, top-of-funnel. Doesn't notify other RPs.
POST /backchannel-logout HTTP/1.1
Host: rp.example
Content-Type: application/x-www-form-urlencoded
logout_token=eyJ… // a signed JWT
// events: http://schemas.
// openid.net/event/
// backchannel-logout
The pattern that actually works at scale.
<!-- OP's logout page -->
<iframe src="https://rp1/fcl?
iss=…&sid=…">
<iframe src="https://rp2/fcl?
iss=…&sid=…">
Use only as a fallback when back-channel isn't available.
Pair RP-Initiated (so the user feels logged out at the OP) with Back-Channel (so other RPs are reliably told). Use Front-Channel only for legacy RPs that can't host a back-channel endpoint. Always check the sid/events/iss claims on the logout token — and confirm the token came from the OP you actually used.
A user-bearing token at API A, an authenticated request from A to downstream API B that needs to preserve the original user identity. Token Exchange is the standardised way; it's also Microsoft's "OBO" flow.
POST /token HTTP/1.1
Host: op.example
Authorization: Basic base64(api_a:secret)
Content-Type: application/x-www-form-urlencoded
grant_type=urn:ietf:params:oauth:grant-type:token-exchange&
# the inbound token API A received from the user
subject_token=eyJ… &
subject_token_type=urn:ietf:params:oauth:token-type:access_token&
# (optional) act-on-behalf-of identity
actor_token=eyJ… &
actor_token_type=urn:ietf:params:oauth:token-type:jwt&
# what API A needs at API B
audience=https://api-b.example&
scope=invoices:read
# response — a token usable by API A to call API B,
# with both 'sub' (the user) and 'act' (API A) preserved
{ "access_token": "eyJ…",
"issued_token_type":
"urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 600 }
{ "iss":"op.example",
"sub":"user_42", // original user
"aud":"api-b",
"scope":"invoices:read",
"act": { "sub":"api_a" } // who acted on their behalf
}
Microsoft "OBO", AWS Cognito "token vending", Auth0 "token exchange", Keycloak "token exchange feature" — all RFC 8693 with vendor garnish.
OpenID Federation 1.0 (final, Sep 2024) replaces "every RP statically configures every OP" with cryptographically verifiable trust chains. It's how you scale OIDC across thousands of organisations.
eIDAS 2.0 / EUDI Wallet, the EU eHealth Network, the Brazilian Open Insurance ecosystem, EduGAIN's OIDC profile — all standardised on Federation 1.0 in late 2024 / 2025 because per-pair pre-registration is intractable at country scale.
eIDAS 2.0 (in force May 2024; member-state rollout to 2026) requires every EU Member State to issue an interoperable EU Digital Identity Wallet (EUDIW). It is the largest planned deployment of OIDC + Verifiable Credentials in the world.
The wallet-era OIDC is three coordinated specs. Each is a small extension of OIDC that swaps "the OP is a server in the cloud" for "the OP is the user's device".
iss = a DID or a JWK thumbprint, not an HTTPS URL.given_name, date_of_birth, document_number from a credential of type org.iso.18013.5.1.mDL".Not all wallet-era credentials live in the OIDC tent. The Mobile Driving Licence (mDL) is defined by ISO/IEC 18013-5 (NFC / BLE proximity) and ISO/IEC 18013-7 (online presentation). Most major US states and many EU/Asia jurisdictions are deploying it.
| SD-JWT VC | mDoc / mDL | |
|---|---|---|
| Encoding | JSON / JWT | CBOR / COSE |
| Origin | IETF / OIDF | ISO TC 204 |
| Selective disclosure | SD-JWT | mdoc disclosure |
| Online flow | OpenID4VP | OpenID4VP (18013-7) |
| Offline / proximity | n/a | NFC / BLE (18013-5) |
| EUDI primary | yes | secondary |
| US state DMV primary | secondary | yes |
Both formats are being shipped in parallel. Wallets typically support both; RPs that want broad coverage need both verification paths. OpenID4VP is the common online transport.
OIDC isn't just for human logins. Modern CI/CD and Kubernetes use it to issue workload identities — short-lived JWTs that replace long-lived API keys.
# GHA mints an OIDC token
permissions:
id-token: write
steps:
- uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123:role/deploy
aws-region: eu-west-1
# AWS STS verifies via
# https://token.actions.githubusercontent.com/.well-known/jwks
# and assumes the role if the token's
# 'sub' = "repo:org/repo:ref:refs/heads/main"
# matches the role's trust policy
token.actions.githubusercontent.com.google-github-actions/auth; receives a short-lived GCP access token./var/run/secrets/tokens/gha with a configurable aud.kubectl get --raw /.well-known/openid-configuration).sub, not just iss + aud — otherwise any repo at token.actions.githubusercontent.com can assume the role.repository_owner + repository + branch / environment.claims Parameterclaims request parameterMost RPs accept whatever default claims the OP sends. The claims parameter (OIDC Core §5.5) lets you ask for specific claims, mark them essential, and even pin acceptable values.
POST /par
claims={
"id_token": {
"acr": { "essential": true,
"values": ["urn:mace:incommon:iap:gold"] },
"auth_time": { "essential": true },
"email": null,
"email_verified":{ "essential": true, "value": true }
},
"userinfo": {
"given_name": null,
"family_name": null,
"address": { "essential": true }
}
}
"Essential" lets the OP know "if you can't satisfy this, prompt the user / step up — don't silently issue a weaker assertion."
The OP embeds a second signed JWT inside the ID token, issued by a different authority. Useful when, say, the user's age-of-majority is asserted by a government issuer but their email is asserted by the social OP.
// inside the ID token
"_claim_names": { "age_over_18": "src1" },
"_claim_sources": {
"src1": { "JWT": "eyJ…signed-by-gov-issuer…" }
}
Same idea, but instead of an embedded JWT the OP gives you a URL + a token to fetch the claim from a separate authority.
"_claim_names": { "tax_status": "src2" },
"_claim_sources": {
"src2": {
"endpoint": "https://hmrc.example/claims/4321",
"access_token": "eyJ…"
}
}
Different iss, different aud, different exp. The bug to avoid is treating an aggregated claim as if the parent OP's signature blessed it — only the inner issuer can vouch for the inner claim.
prompt, max_age, acr_values, amrThe auth event itself has metadata. These four parameters/claims let an RP demand fresh / strong authentication, and verify it actually happened.
| Parameter / claim | Direction | Job |
|---|---|---|
prompt=login | RP → OP, on /authorize | Force re-authentication even if the user has a live SSO session. |
prompt=consent | RP → OP | Force the consent screen even if remembered. |
prompt=none | RP → OP | Silent — fail rather than display UI. Useful for token refresh / SSO check. |
max_age=N | RP → OP | Refuse if the user's last login was more than N seconds ago. |
acr_values | RP → OP | Request a specific Authentication Context Class — e.g. urn:mace:incommon:iap:silver, or http://eidas.europa.eu/LoA/high. |
auth_time | OP → RP, in ID token | Unix time of the actual authentication event. |
acr | OP → RP, in ID token | The class actually achieved (may be lower than requested if the OP couldn't satisfy it). |
amr | OP → RP, in ID token | Array of methods used: e.g. ["pwd","otp"], ["pwd","webauthn"], ["face","hwk"]. |
# user is logged in (with password) but is now trying to
# initiate a £10k transfer. RP forces fresh MFA:
GET /authorize?
client_id=…&
scope=openid&
prompt=login&
max_age=120&
acr_values=urn:mace:incommon:iap:gold&
claims={"id_token":{"acr":{"essential":true,
"values":["urn:mace:incommon:iap:gold"]}}}
# resulting ID token must include
"acr": "urn:mace:incommon:iap:gold",
"amr": ["pwd","webauthn"],
"auth_time": 1800002000
Sending acr_values but not verifying the returned acr matches. The OP is allowed to step down; if you don't check, your "MFA-protected" code path runs after a password-only login.
Always: request as parameter, demand as essential claim, verify on receipt.
oidc.token.validate.failure{reason} — labelled by signature, aud, iss, exp, nonce, kid, at_hash. Spikes in kid-failures = key rotation drama.oidc.jwks.refresh{outcome} — alert if more than 1 % of validations trigger a refresh.oidc.flow.duration_ms — quantiles by step (par · authorize · token · userinfo). Most outages first show as a token-endpoint p99 climb.oidc.refresh.reuse_detected — every event is a probable token theft.iss, sub (hashed if user-linkable in cold storage), aud, azp, scope, amr, acr, auth_time.jti + a flow-id you generate on /authorize and propagate through.acr downgrades (the OP is failing to enforce step-up)./par with no follow-up /authorize (probing).NameID into the OIDC sub with care — once-only mapping table.AuthnContextClassRef values to OIDC acr_values.openid scope to your existing flows.sub./.well-known/openid-configuration) and start consuming JWKS.https://login.acme.com/) before launch — every RP, mobile app, partner integration will pin it.| Domain | Profile | Stack |
|---|---|---|
| UK Open Banking | FAPI 1 → FAPI 2 | OIDC OP at the bank · PAR · mTLS · private_key_jwt · request objects · UK Trust Framework directory. |
| Open Banking Brazil | FAPI 2 + Message Signing | JAR + JARM + DPoP/mTLS · OpenID Federation 1.0 · Brazilian Central Bank as trust anchor. |
| eIDAS 2.0 / EUDI Wallet | SIOPv2 + OpenID4VCI + OpenID4VP | PID Provider issues SD-JWT VC into the wallet · RP requests via OpenID4VP · Federation 1.0 trust chain to a national TA. |
| FHIR / SMART on FHIR | OAuth 2 + OIDC + scopes like patient/Observation.read |
OIDC OP + EHR as RS · launch context via launch parameters · sometimes FAPI in regulated jurisdictions. |
| EduGAIN / Research | OpenID Federation 1.0 + REFEDS schema | Federation across 5,000+ universities, replacing SAML eduGAIN with OIDC equivalents. |
| US Government LOA / NIST 800-63 | OIDC + acr_values mapped to IAL/AAL |
Login.gov / ID.me as OPs · AAL2/AAL3 enforced via acr_values + amr. |
| B2B SaaS multi-tenant | OIDC OP + SCIM + SAML fallback | WorkOS / Auth0 / Okta CIC as broker · per-tenant identity provider connections · SCIM for provisioning. |
| Workload identity | OIDC issuer per platform | GitHub Actions / GCP Workload Identity / Kubernetes ServiceAccountTokenVolume / SPIFFE/SPIRE · short-lived JWTs only. |
aud/azp, pairwise subjects, PAR, JAR, JARM, FAPI 2.0, mTLS / private_key_jwt / DPoP, three logout specs, Token Exchange.prompt / max_age / acr_values / amr), observability, migration playbooks, domain reference architectures.Introduction to OpenID Connect — the foundations this deck assumes.
OAuth — A Gentle Primer · Introduction to OAuth · OAuth for MCP Servers — the OAuth series.
aud with azp. The single biggest class of OIDC bugs is treating multi-audience tokens as single-audience.private_key_jwt — and certify, don't self-attest.OpenID Connect Core 1.0 · OIDC Discovery · OIDC Dynamic Client Registration · OIDC RP-Initiated / Back-Channel / Front-Channel Logout · CIBA Core 1.0 · SIOPv2 · OpenID for Verifiable Credential Issuance (OpenID4VCI) · OpenID for Verifiable Presentations (OpenID4VP) · OpenID Federation 1.0 (Sep 2024) · FAPI 2.0 Security Profile · FAPI 2.0 Message Signing · ISO/IEC 18013-5 / 18013-7 (mDL) · eIDAS 2.0 Regulation (EU) 2024/1183 · SD-JWT (draft-ietf-oauth-selective-disclosure-jwt) · SD-JWT VC · RFC 7515/7517/7519 (JWS/JWK/JWT) · RFC 7523 (JWT client auth) · RFC 8414 / 8628 / 8693 / 8705 / 8707 · RFC 9068 (JWT AT) · RFC 9101 (JAR) · RFC 9126 (PAR) · RFC 9207 (iss param) · RFC 9396 (RAR) · RFC 9449 (DPoP) · RFC 9700 (BCP 240) · RFC 9728 (Protected Resource Metadata) · openid.net/certification
The basic protocol is a small spec; production OIDC is the profiles. Pick the right profile for your domain (FAPI 2.0, EUDI, workload), and certify it.