OpenID Connect · OAuth 2.0
These mechanisms strengthen the security of the base flow. Each one answers a specific weakness: we start with the problem, then the solution.
The common thread
The basic flow sends information through the browser, where anyone can tamper with it. Advanced features fix that at each step: PAR and JAR protect the request, private_key_jwt protects the application's identity, JARM protects the response, DPoP protects the token once issued, and CIBA covers the case where there is no browser. On top of that come resource (which API), RAR (which precise operation), acr (which authentication level) and dynamic registration of clients.
private_key_jwt · PAR · JAR · JARM · DPoP · CIBA · Resource indicators · RAR · acr and step-up · Dynamic client registration · Pairwise and signed userinfo
RFC 7523 / OIDC Core 9
Prove who you are with a signature rather than a shared password.
A confidential client normally authenticates with a "client_secret", a password that the client and the server both know. If it leaks on one side, the other has no way of knowing, and the secret travels through configuration files.
The client keeps a private key that never leaves its premises. On each call, it builds a small signed "attestation" (a JWT) saying "I am this client, I am addressing this server, and this is valid for a few minutes only". The server only has the public key: it verifies the signature, that's all.
As a picture
Instead of a password the guard knows, you sign each entry with your personal pen; the guard only has a specimen of your signature.
Banks, public administrations, any environment that forbids shared secrets. Mandatory in several security profiles (FAPI).
Replayed assertion (hence the single-use "jti" identifier), wrong audience, lifetime too long, wrong key.
Create a "Confidential with key (private_key_jwt)" client in your test provider, paste your certificate or public JWKS, then call /token with client_assertion. ID-Kit tells you precisely why an assertion is rejected.
RFC 9126
Submit your sign-in request directly to the server, without passing it through the browser.
Normally, the request (who I am, what I want, where to return) is written in the page address and travels through the browser: the user or malicious software can read or change it, and very long addresses are a problem.
The application first sends its entire request to the server, server to server, authenticating itself. The server answers with a simple "ticket" (request_uri) valid for one minute. The application then sends the browser to the server with only that ticket. The ticket can only be used once.
As a picture
Instead of shouting your order across the dining room, you hand it to the kitchen and only show the room a ticket number.
Whenever you want to prevent a parameter from being tampered with (amount, scopes, redirect_uri): banking, healthcare, payment. Required by FAPI.
Ticket expired (60 s), already used, or presented by a client other than the one that submitted it.
Test provider: call /par. On the test application side, choose the "code_par" flow. The provider's "Require PAR" setting rejects classic requests.
RFC 9101
Sign the sign-in request so that nobody can modify it on the way.
Even with the parameters in the address, nothing proves they really come from the application and have not been changed.
The parameters are gathered in a JWT signed with the client's key ("request" parameter). The server verifies the signature, issuer, audience and date. The JWT's parameters replace those of the address.
As a picture
A letter in an envelope sealed with wax: if the seal is broken, the server sees it.
With PAR in high-security profiles; alone, when there is no server-to-server channel.
Client public key not registered, "aud" that is not the server's issuer, expired JWT, different client_id.
Register the client's public key then send your JWT in "request" or to /par. Bad signatures and audiences are explained.
FAPI JARM
The server also signs its response: the application knows it really comes from it and has not been modified.
When the server sends the user back to the application with "code=…" in the address, anyone in the browser can touch it. This opens the door to attacks where another server impersonates the right one ("mix-up").
Instead of several plain parameters, the server returns a single "response" parameter: a signed JWT containing code, state, the issuer (iss) and the audience (aud). The application verifies the signature, then reads the code inside. Four variants: jwt, query.jwt, fragment.jwt, form_post.jwt (where the JWT is placed).
As a picture
The response arrives in a sealed envelope rather than on a postcard.
FAPI 2.0 profile and regulated sectors. Also useful to detect mix-up attacks between servers.
Forgetting to verify the signature, or reading plain parameters next to the JWT. query.jwt is forbidden when tokens come back through the browser.
Add response_mode=jwt (or a variant) to your authorization request on the test provider: even a denial or an error is signed.
RFC 9449
A stolen token is useless without the key of the application that requested it.
A "Bearer" token works like cash: whoever holds it can use it, even if they stole it from a log, a browser extension or a compromised API.
The application creates a key pair. On each call, it attaches a short "proof" signed with its private key, saying "here is the method and the address I am calling, at this instant". The server binds the token to the key's thumbprint (cnf.jkt). The API checks that the proof was signed by the same key and that it carries the token's hash (ath). A thief who has the token but not the private key is blocked.
As a picture
A bank card with a PIN, rather than banknotes: the card alone is not enough.
Mobile apps and web pages (SPA) where tokens are exposed, sensitive APIs, FAPI 2.0 profiles.
Replayed proof (single-use jti), wrong method or address (htm, htu), skewed clock (iat ±2 min), token presented with "Bearer" instead of "DPoP", ath missing on the API.
Send a DPoP header to the test provider's /token: the token becomes token_type DPoP with cnf.jkt. On /userinfo, you need "Authorization: DPoP" plus a proof with ath, otherwise a message explains what is missing.
OpenID CIBA Core
An application asks the server to authenticate someone on their phone, without a browser or redirect.
At a counter, a call center, a payment terminal or a voice assistant, the person is not in front of a browser that can redirect.
The application tells the server "authenticate Alice" (login_hint), optionally with a message to display ("Authorize the payment of 25 €?") and receives an auth_req_id. The server contacts Alice on her phone. Meanwhile, the application polls /token: "authorization_pending" as long as Alice has not answered, then the tokens if she accepts, or "access_denied" if she refuses.
As a picture
The seller calls your bank to say "my customer is here", and it is your bank that sends you a notification to confirm.
Payment or sensitive operation validation, call centers, kiosks, telephony. The binding message (binding_message) appears on both screens to check that the right operation is being validated.
Polling too fast (slow_down), letting the request expire, forgetting that login_hint must designate a user known to the server.
Enable the CIBA flow on a client of the test provider, call /bc-authorize. The response contains an "idkit_approval_url" link: it is the "phone" page, where you approve or deny, then you fetch the tokens with /token. ID-Kit only supports poll mode.
RFC 8707
Say which API you want to talk to, so that the token only works there.
An access token valid for "any API" is dangerous: if the billing API is compromised, it can replay the token against the payroll API.
The application adds "resource=https://api.example.com/invoices" to its request (authorization or token). The provider puts this address in the token's audience (aud). The API checks that aud is itself, and refuses otherwise.
As a picture
A named train ticket for a specific line, rather than a ticket valid everywhere.
As soon as there are several APIs behind the same provider, and for sensitive tokens.
API that does not check aud; resource with a fragment (#) or that is not an absolute address (invalid_target).
Add resource to /authorize or /token of the test provider: the audience of the JWT token and of introspection changes. The resource is remembered in the code and kept on refresh.
RFC 9396
Ask for a precise right ("pay 25 € to X") rather than a broad scope ("pay").
A scope like "payments" is too vague: the user does not know what they approve, and the token allows far more than the intended operation.
The application sends authorization_details: a JSON list of objects with a "type" (for example payment_initiation) and the operation details (amount, beneficiary). The provider displays them, copies them into the token and the response, and the API reads them.
As a picture
A filled-in check (amount, payee) rather than a blank check.
Payment, healthcare, fine-grained delegation. Mandatory in some banking profiles.
Invalid JSON or missing "type" (invalid_authorization_details); API that ignores the token content.
Send authorization_details to /authorize or /token: ID-Kit validates it and finds it again in the token, the /token response and introspection.
OIDC Core, RFC 9470
Know how strongly the person authenticated, and require more if needed.
Signing in with a password is not enough to transfer 10 000 €. The application must be able to say "I want strong authentication".
The application asks for acr_values (for example the "mfa" level). The provider returns in the ID token the acr value obtained and the amr list of methods used (password, one-time code…). An API can reject a token of too low a level and ask for a "step-up": the person goes back for a stronger authentication.
As a picture
At the bank, you are allowed to view your account with your card, but for a large transfer you are also asked for an ID document.
Sensitive operations, compliance (strong authentication).
Treating acr_values as a requirement when it is a request ("voluntary"); not checking the acr received.
Add acr_values=urn:idkit:acr:mfa: the ID token contains acr and amr [pwd, otp], and introspection returns acr and auth_time. Without a request, the level is "pwd".
RFC 7591
An application registers itself with the provider, without going through an administrator.
Creating clients by hand does not scale: tools, extensions, MCP servers, mobile apps installed by thousands of people.
The application sends a JSON POST to /register (name, return addresses, wanted flows, authentication method). The provider creates the client and returns client_id and, if needed, client_secret.
As a picture
A self-service registration kiosk instead of an appointment at the front desk.
Open ecosystems, tools that plug into unknown providers.
Unsafe redirects (http outside localhost), grants inconsistent with the authentication method, no limit.
Enable "Allow dynamic registration" in the test provider settings, then POST to /register. The limit is 25 clients.
OIDC Core 8.1 and 5.3.2
A different identifier per application, and signed profile information.
If all applications see the same identifier (sub) for a person, they can compare their databases and follow them from one service to another.
In pairwise mode, the provider computes a different sub for each application domain: the same user appears under two different identifiers at two applications. Separately, /userinfo can answer with a signed JWT instead of plain JSON, so the application can prove where the information comes from.
As a picture
A different pseudonym per site instead of a social security number everywhere.
Privacy (public administrations, healthcare, education); environments where profile information must be signed.
Linking accounts across applications with sub (impossible with pairwise); forgetting that sub in userinfo must be identical to the one in the ID token.
Two settings on the test provider: "Per-application identifier (pairwise)" and "userinfo as signed JWT".
See also: sequence diagrams · which flow to choose? · the timeline