How OpenID Connect Works, Message by Message
OpenID Connect (OIDC) is the OAuth 2.0 authorization code flow plus one extra artifact: a signed ID token that tells the relying party (RP) who logged in, when and how. On the wire that is five HTTP exchanges: discovery, the authentication request, the redirect back with a code, the token request, and optionally a userinfo call. This page shows each one with realistic, fake values, explains every parameter with its section in OpenID Connect Core 1.0 or the relevant RFC, and ends with the checks an RP must run and the mistakes that became CVEs.
For what OIDC is, its status and where it fits, see the OpenID Connect standard page. For the OAuth layer underneath, including client credentials and refresh, see how OAuth 2.0 and 2.1 work, message by message.
The sequence at a glance
| # | From → to | Message | Spec |
|---|---|---|---|
| 0 | RP → OP | GET /.well-known/openid-configuration |
Discovery §4 |
| 1 | RP → browser → OP | Authentication request to the authorization endpoint | Core §3.1.2.1 |
| 2 | OP ↔ user | Login, MFA, consent | Core §3.1.2.3–3.1.2.4 |
| 3 | OP → browser → RP | Redirect with code, state, iss |
Core §3.1.2.5, RFC 9207 §2 |
| 4 | RP → OP (back channel) | Token request with code and code_verifier |
Core §3.1.3.1, RFC 7636 §4.5 |
| 5 | OP → RP | Token response: id_token, access_token, optional refresh_token |
Core §3.1.3.3 |
| 6 | RP → OP | Optional userinfo request with the access token | Core §5.3 |
Step 0: discovery
GET /.well-known/openid-configuration HTTP/1.1
Host: login.example.com
{
"issuer": "https://login.example.com",
"authorization_endpoint": "https://login.example.com/authorize",
"token_endpoint": "https://login.example.com/oauth/token",
"userinfo_endpoint": "https://login.example.com/userinfo",
"jwks_uri": "https://login.example.com/.well-known/jwks.json",
"response_types_supported": ["code"],
"subject_types_supported": ["public", "pairwise"],
"id_token_signing_alg_values_supported": ["RS256", "ES256"],
"code_challenge_methods_supported": ["S256"],
"authorization_response_iss_parameter_supported": true,
"token_endpoint_auth_methods_supported": ["private_key_jwt", "client_secret_basic"]
}
| Field | Why the RP cares | Spec |
|---|---|---|
issuer |
The exact string every ID token's iss must equal. It must also match the URL you fetched the document from. |
Discovery §3, §4.3 |
jwks_uri |
Where the signing keys live. Cache with a TTL and refetch on an unknown kid. |
Discovery §3 |
id_token_signing_alg_values_supported |
What the OP may sign with. Your RP should accept only the one algorithm you registered. | Discovery §3 |
code_challenge_methods_supported |
Whether PKCE S256 is available (it should be). |
RFC 8414 §2 |
authorization_response_iss_parameter_supported |
The OP adds iss to the redirect, which defeats mix-up attacks. |
RFC 9207 §3 |
Step 1: the authentication request
The RP generates three random values per login and stores them in the user's pre-login session: state, nonce, and a PKCE code_verifier (43 to 128 characters, RFC 7636 §4.1). It sends the SHA-256 of the verifier as the challenge.
GET /authorize?response_type=code
&client_id=s6BhdRkqt3-example
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=af0ifjsldkj-example
&nonce=n-0S6_WzA2Mj-example
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: login.example.com
| Parameter | Meaning | Spec |
|---|---|---|
response_type=code |
Authorization code flow, the one to use. | Core §3.1.2.1 |
scope=openid ... |
openid is what makes this OIDC; profile and email request standard claims. |
Core §3.1.2.1, §5.4 |
client_id |
The RP's registered identifier; becomes the ID token aud. |
RFC 6749 §2.2 |
redirect_uri |
Must exactly match a registered value. | Core §3.1.2.1, RFC 9700 §2.1 |
state |
Opaque CSRF token you check on return. | Core §3.1.2.1, RFC 6749 §10.12 |
nonce |
Copied by the OP into the ID token, binding it to this browser session. | Core §3.1.2.1, §15.5.2 |
code_challenge, code_challenge_method=S256 |
PKCE: proves the party redeeming the code started the request. | RFC 7636 §4.2–4.3 |
prompt, max_age, acr_values (optional) |
Force re-login, cap authentication age, request an assurance level. | Core §3.1.2.1 |
Step 3: the callback
HTTP/1.1 302 Found
Location: https://app.example.com/callback?code=SplxlOBeZQQYbYS6WxSbIA-example
&state=af0ifjsldkj-example
&iss=https%3A%2F%2Flogin.example.com
The code is single-use and short-lived; RFC 6749 recommends a maximum lifetime of 10 minutes (§4.1.2). The RP checks state against the session (Core §3.1.2.7) and iss against the expected issuer (RFC 9207 §2.4) before doing anything else.
Step 4: the token request
POST /oauth/token HTTP/1.1
Host: login.example.com
Content-Type: application/x-www-form-urlencoded
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA-example
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
&client_id=s6BhdRkqt3-example
&client_assertion_type=urn%3Aietf%3Aparams%3Aoauth%3Aclient-assertion-type%3Ajwt-bearer
&client_assertion=eyJhbGciOiJQUzI1NiIsImtpZCI6ImNsaWVudC1rZXktMSJ9.eyJpc3MiOiJzNkJoZFJrcXQzLWV4YW1wbGUiLCJhdWQiOiJodHRwczovL2xvZ2luLmV4YW1wbGUuY29tIn0.c2lnbmF0dXJlLWV4YW1wbGU
| Parameter | Meaning | Spec |
|---|---|---|
grant_type=authorization_code, code |
Redeem the code from step 3. | RFC 6749 §4.1.3 |
redirect_uri |
Must be identical to the one in step 1 if it was sent there. | RFC 6749 §4.1.3 |
code_verifier |
The server hashes it and compares to the stored challenge. | RFC 7636 §4.5–4.6 |
client_assertion (private_key_jwt) |
A short JWT signed by the client's private key. Its aud must identify this authorization server; the 2025 audience findings in the mistakes table show what happens when it is loose. |
RFC 7523 §2.2, §3, Core §9 |
The example PKCE values are the illustrative ones from RFC 7636 Appendix B. Never reuse them.
Step 5: the token response and the ID token
{
"access_token": "SlAV32hkKG-example",
"token_type": "Bearer",
"expires_in": 900,
"refresh_token": "8xLOxBtZp8-example",
"id_token": "eyJhbGciOiJSUzI1NiIsImtpZCI6IjIwMjYtMTAta2V5LWEiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOi...signature"
}
The response must carry Cache-Control: no-store (Core §3.1.3.3). Decoded, the ID token header and payload look like this:
{ "alg": "RS256", "kid": "2026-10-key-a", "typ": "JWT" }
{
"iss": "https://login.example.com",
"sub": "248289761001-example",
"aud": "s6BhdRkqt3-example",
"exp": 1793887631,
"iat": 1793887331,
"auth_time": 1793887322,
"nonce": "n-0S6_WzA2Mj-example",
"acr": "phr",
"amr": ["hwk", "user"],
"at_hash": "77QmUPtjPfzWtF2AnpK9RQ",
"email": "ada@example.com",
"email_verified": true
}
| Claim | Meaning | Spec |
|---|---|---|
iss |
Issuer identifier, compared by exact string match. | Core §2 |
sub |
Stable, never-reassigned identifier within this issuer. The account key is iss + sub. |
Core §2, §5.7 |
aud |
Must contain your client_id. Can be an array. |
Core §2 |
exp, iat |
Expiry and issue time, seconds since the epoch. | Core §2 |
auth_time |
When the user actually authenticated; required if you sent max_age. |
Core §2 |
nonce |
Echo of your request value. | Core §2 |
acr, amr |
Assurance class and methods used. Values are deployment-specific; amr values are registered in RFC 8176. |
Core §2 |
at_hash |
Left half of the hash of the access token, base64url. Lets you confirm the access token came with this ID token. | Core §3.1.3.6 |
email, email_verified |
Profile claims. Display them; do not key accounts on them. | Core §5.1 |
Step 6: userinfo (optional)
GET /userinfo HTTP/1.1
Host: login.example.com
Authorization: Bearer SlAV32hkKG-example
The sub in the userinfo response must exactly match the sub in the ID token, or the RP must not use the response (Core §5.3.2).
The checks a relying party must run
statematches the value in the user's pre-login session, then discard it (Core §3.1.2.7).isson the redirect equals the expected issuer when the OP supports RFC 9207 (RFC 9207 §2.4).- ID token
issexactly equals the discoveredissuer(Core §3.1.3.7). audcontains yourclient_idand no audiences you do not trust; ifazpis present, it is yourclient_id(Core §3.1.3.7).- Signature verifies with a key from the issuer's JWKS, using the algorithm you registered. Reject
alg: none, reject symmetric algorithms you did not configure, and never take the key from the token header (Core §3.1.3.7, §10.1). expis in the future andiatis within your acceptable window (Core §3.1.3.7).nonceis present and equals the value you sent (Core §3.1.3.7).acrandauth_timesatisfy what you requested, if you requested them (Core §3.1.3.7).
The JWT layer underneath has its own rules: RFC 7519 §7.2 for validating a JWT, and the JWT best current practices in RFC 8725 for algorithm allow-lists.
Common implementation mistakes, with the CVEs they produced
| Mistake | What goes wrong | Real examples |
|---|---|---|
Accepting unsigned tokens or alg: none |
Anyone can mint an ID token | CVE-2026-48558 (SimpleHelp, exploited), CVE-2023-29357 (SharePoint) |
| Accepting algorithms you never configured, or picking the key from attacker input | Key confusion; HS256 verified with a public key or a looked-up secret | CVE-2026-5430 (WSO2), CVE-2025-65945 (node-jws), CVE-2025-9485 |
Loose aud on private_key_jwt client assertions |
An assertion meant for one server is replayed at another | CVE-2025-27370, CVE-2025-27371, technique: token replay at an unbound endpoint |
| Expired or retired keys still trusted | A key you rotated out keeps working | CVE-2025-31123 (Zitadel) |
| Re-authentication not bound to the original user | prompt=login or account linking merges the wrong session |
CVE-2023-6787, CVE-2025-7365 (Keycloak) |
| Accepting any tenant of a multi-tenant issuer | A user from any tenant signs in | cross-tenant token confusion |
Keying accounts on email |
Account takeover through an unverified or reassigned email | Pattern; use iss + sub |
| Calling APIs with the ID token | The API accepts a token whose audience is the client | Send the access token; see the OAuth walkthrough |
None of these checks stop a user who completes a real login on a phishing proxy; the proxy steals the resulting session. That is fixed at the OP with phishing-resistant authentication, see how WebAuthn works, message by message and AiTM phishing.
Try it
Run your provider through the OIDC discovery checker to confirm the issuer, JWKS and supported algorithms, then paste an ID token into the JWT decoder and compare each claim to the table above. The validate a JWT recipe and the authorization code with PKCE recipe turn the checklist into code.
Related
OpenID Connect standard page, OAuth vs OIDC, SAML message flow, implementing OAuth 2.0 and OIDC. Glossary: ID token, JWT, JWKS, PKCE, relying party, claims.
Frequently asked questions
- How does OIDC work?
- OpenID Connect runs on top of the OAuth 2.0 authorization code flow. The relying party reads the provider's discovery document, redirects the browser to the authorization endpoint with scope=openid, a state, a nonce and a PKCE challenge, receives a one-time code on its redirect URI, and exchanges that code plus the PKCE verifier at the token endpoint. The token response carries an ID token, a signed JWT that says who logged in, when and how, plus an access token for the userinfo endpoint or APIs.
- What is the difference between OIDC and OAuth 2.0 in the flow?
- The messages are the same OAuth authorization code flow. OIDC adds the openid scope, the nonce parameter, the ID token in the token response, the userinfo endpoint and the discovery document. OAuth alone gives the client an access token for an API; OIDC adds a verifiable statement about the user's authentication that the client itself consumes.
- Which ID token claims must be validated?
- OIDC Core section 3.1.3.7 requires the client to check that iss exactly matches the issuer, that aud contains its client_id and no untrusted audiences, that the signature verifies with the issuer's keys using the expected algorithm, that exp is in the future, and that nonce matches the value it sent. iat, acr and auth_time should be checked when relevant.
- Do I need both state and nonce if I use PKCE?
- PKCE protects the code exchange. state protects the redirect against cross-site request forgery and carries your own session context, and nonce binds the ID token to the browser session that started the login. RFC 9700 accepts PKCE as the primary defense against code injection, but most libraries send all three, and OIDC Core requires a nonce match whenever a nonce was sent.
Related on Start with Identity
- GuideHow Kerberos Works, Message by Message
Kerberos V5 on the wire: AS-REQ, pre-authentication, AS-REP, TGS-REQ and TGS-REP, AP-REQ and AP-REP, with each ASN.1 field tied to its RFC 4120 section, the che
- GuideHow LDAP Works, Message by Message
LDAP on the wire: StartTLS, the service-account bind, the user search, the user bind and the result codes, with each LDAPMessage field tied to RFC 4511, RFC 451
- GuideHow RADIUS Works, Message by Message
RADIUS on the wire: the packet header, an annotated Access-Request, Access-Challenge for a one-time code, Access-Accept with authorization attributes, and accou
- GlossaryOpenID Connect (OIDC)
OpenID Connect is an authentication layer built on top of OAuth 2.0. Where OAuth tells you what a token is authorized for, OIDC tells you who the user is via a
- BlogCISA adds the last two Ivanti Connect Secure chain flaws to KEV after Ivanti's October 29 patch
CISA added Ivanti Connect Secure CVE-2026-111357 and CVE-2026-118527 to its exploited list on October 30, a day after Ivanti patched the root escalation UTA0576
- ExpertCo-chair, OpenID Shared Signals working group
Annabelle Backman is a principal security engineer on AWS Identity and co-chairs the OpenID Shared Signals and Events working group. She edited RFC 8935 and RFC