Introduction
As of 2026, login for virtually every newly built system has converged on OpenID Connect (OIDC). Social login, corporate SSO, mobile apps, even the login of CLI tools — underneath them all flows the OIDC Authorization Code Flow. And yet incidents keep happening to teams who know OIDC only as "a few lines of library configuration", through holes like skipped token validation or unused nonce values.
This article dissects OIDC at the protocol level. We read the HTTP requests and responses directly, separate the roles of the three tokens, understand how Discovery and JWKS behave, and finish with a practical token validation checklist. Since the OAuth 2.1 draft has become the de facto baseline, every explanation assumes Code + PKCE.
The Layered Structure of OIDC — What Was Stacked on OAuth 2.0
OIDC does not replace OAuth 2.0. **It reuses the OAuth 2.0 flow as-is and adds an authentication layer on top.**
+---------------------------------------------------------------+
| What OIDC adds |
| - ID Token (signed JWT, the standard format of auth results) |
| - scope=openid and standard claims (sub, email, name...) |
| - Discovery (/.well-known/openid-configuration) |
| - JWKS (signing key distribution), UserInfo endpoint |
| - auth-only parameters: nonce, max_age, prompt, acr, ... |
| - RP-Initiated / Back-Channel Logout |
+---------------------------------------------------------------+
| What OAuth 2.0 (RFC 6749) provides |
| - authorize / token endpoints and the grant flows |
| - access token, refresh token |
| - client registration and redirect_uri validation |
+---------------------------------------------------------------+
| Foundation: HTTP + TLS, JWT (RFC 7519), JWS/JWK |
+---------------------------------------------------------------+
The vocabulary shifts on top of OAuth too: the OAuth client becomes the **RP (Relying Party)** in OIDC, and the authorization server becomes the **OP (OpenID Provider)**.
The Three Tokens — Never Mix Their Roles
| Aspect | ID Token | Access Token | Refresh Token |
| --- | --- | --- | --- |
| Audience | RP (the client) | Resource Server (the API) | The token endpoint of the OP |
| Purpose | Proof that "the user authenticated" | Permission to call APIs | Reissuing new tokens |
| Format | Always a JWT | Free (JWT or opaque) | Free (usually opaque) |
| Validated by | The RP itself (signature/claims) | The API server | Only the OP interprets it |
| Lifetime | Short (minutes) | Short (5-15 min recommended) | Long (rotation required) |
| Where it must not go | Do not send to APIs | Do not use as login proof | Never leaves the RP |
The two most common misuses:
1. **Sending the ID Token to an API as a Bearer token** — the aud of an ID Token is the client_id, not the API. An API that accepts it has abandoned audience validation.
2. **Treating an Access Token as login** — an access token carries no audience-bound guarantee about "who authenticated". Login decisions are made with the ID Token only.
Looking Inside an ID Token
An ID Token is a JWS with three parts (header.payload.signature).
{
"alg": "RS256",
"typ": "JWT",
"kid": "rsa-key-2026-05"
}
{
"iss": "https://idp.corp.com/realms/prod",
"sub": "f9a8b7c6-1234-5678-90ab-cdef12345678",
"aud": "web-dashboard",
"exp": 1781258400,
"iat": 1781258100,
"auth_time": 1781258095,
"nonce": "n-0S6_WzA2Mj",
"acr": "urn:mace:incommon:iap:silver",
"azp": "web-dashboard",
"email": "yj.kim@corp.com",
"email_verified": true,
"name": "Youngju Kim",
"preferred_username": "yj.kim"
}
- iss: the issuer. Must match the issuer in the Discovery document **as an exact string**.
- sub: the immutable identifier of the user. **Account matching must use the iss+sub pair, not email.** Emails change, get reused, and may be unverified.
- aud / azp: the audience of this token (my client_id) — the basis for rejecting tokens issued to other apps.
- exp / iat / auth_time: expiry, issuance, and actual authentication time. auth_time is used with max_age for re-authentication policy.
- nonce: an echo of the value sent in the authentication request. The core of replay defense.
Authorization Code Flow + PKCE — An HTTP-level Dissection
The full sequence:
[Browser] [RP server] [OP]
| | |
|-- 1. GET /login ----->| |
| | 2. generate state, |
| | nonce, verifier |
|<- 3. 302 redirect to OP /authorize -----------|
|-- 4. GET /authorize ------------------------->|
|<- 5. Login UI (skipped if SSO session) -------|
|-- 6. Authenticate (passkey/MFA) ------------->|
|<- 7. 302 redirect_uri?code=...&state=... -----|
|-- 8. GET /callback?code=...&state=... ->| |
| | 9. compare state |
| |-- 10. POST /token --->|
| | (code+verifier) |
| |<- 11. token response -|
| | 12. verify id_token, |
| | compare nonce |
|<- 13. session cookie -| |
Step 1: Building the Authentication Request
Before the request, the RP creates three random values.
state: CSRF defense (stored in the session)
state=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
nonce: ID Token replay defense (stored in the session)
nonce=$(openssl rand -base64 32 | tr -d '=+/' | cut -c1-43)
PKCE: code_verifier and its SHA-256 hash, the code_challenge
code_verifier=$(openssl rand -base64 64 | tr -d '=+/' | cut -c1-128)
code_challenge=$(printf '%s' "$code_verifier" \
| openssl dgst -sha256 -binary | openssl base64 -A \
| tr '+/' '-_' | tr -d '=')
Then it sends the browser to the authorization endpoint.
GET /realms/prod/protocol/openid-connect/auth
?response_type=code
&client_id=web-dashboard
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&scope=openid%20profile%20email
&state=af0ifjsldkj
&nonce=n-0S6_WzA2Mj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256 HTTP/1.1
Host: idp.corp.com
Step 2: The Callback and Receiving the Code
When authentication completes, the OP redirects the browser back.
HTTP/1.1 302 Found
Location: https://app.example.com/callback
?code=SplxlOBeZQQYbYS6WxSbIA
&state=af0ifjsldkj
&iss=https%3A%2F%2Fidp.corp.com%2Frealms%2Fprod
The immediate job of the RP: **compare the state in the session with the returned state.** On mismatch, abort right there (a CSRF attempt). If the iss parameter (RFC 9207) is present, also confirm it is the expected issuer to block mix-up attacks.
Step 3: Exchanging the Code for Tokens (back channel)
POST /realms/prod/protocol/openid-connect/token HTTP/1.1
Host: idp.corp.com
Content-Type: application/x-www-form-urlencoded
Authorization: Basic d2ViLWRhc2hib2FyZDpzM2NyM3Q=
grant_type=authorization_code
&code=SplxlOBeZQQYbYS6WxSbIA
&redirect_uri=https%3A%2F%2Fapp.example.com%2Fcallback
&code_verifier=dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk
The OP hashes the code_verifier with SHA-256 and compares it to the code_challenge received earlier. Even if the code is stolen, the exchange is impossible without the verifier — that is PKCE (RFC 7636).
The response:
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"token_type": "Bearer",
"expires_in": 300,
"refresh_token": "eyJhbGciOiJIUzUxMiIs...",
"id_token": "eyJhbGciOiJSUzI1NiIs...",
"scope": "openid profile email"
}
The RP validates the id_token (checklist below), confirms the nonce in the payload matches the value stored in the session, and only then processes the login.
Confidential vs Public Clients
| Aspect | confidential | public |
| --- | --- | --- |
| Examples | Server-rendered web apps, BFF | SPA, mobile, CLI |
| client_secret | Held (or private_key_jwt) | None |
| Token request auth | Basic or mTLS/JWT | client_id only |
| PKCE | Still mandatory under OAuth 2.1 | Mandatory (the only line of defense) |
Under OAuth 2.1, PKCE applies to every client regardless of type. For SPAs, the recommended 2026 structure is not to hold tokens in the browser at all but to become a confidential client via the BFF (Backend For Frontend) pattern.
Discovery — How Not to Hardcode Configuration
The OP publishes all of its configuration at a standard path.
curl -s https://idp.corp.com/realms/prod/.well-known/openid-configuration
{
"issuer": "https://idp.corp.com/realms/prod",
"authorization_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/auth",
"token_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/token",
"userinfo_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/userinfo",
"jwks_uri": "https://idp.corp.com/realms/prod/protocol/openid-connect/certs",
"end_session_endpoint": "https://idp.corp.com/realms/prod/protocol/openid-connect/logout",
"scopes_supported": ["openid", "profile", "email", "offline_access"],
"response_types_supported": ["code"],
"id_token_signing_alg_values_supported": ["RS256", "ES256", "EdDSA"],
"code_challenge_methods_supported": ["S256"]
}
An RP library only needs the issuer URL configured; everything else is read from here. One validation rule matters: **the issuer value inside the document must match the issuer part of the URL the document was fetched from.** Without this check, a malicious OP can impersonate the configuration of another.
As of Keycloak 26.6, EdDSA signing is also supported, so for new builds it is worth considering ES256/EdDSA in addition to RS256.
JWKS and Key Rotation
The jwks_uri serves the list of public keys (a JWK Set) used to verify ID Token signatures.
{
"keys": [
{
"kid": "rsa-key-2026-05",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "0vx7agoebGcQSuuPiLJXZpt...",
"e": "AQAB"
},
{
"kid": "rsa-key-2026-06",
"kty": "RSA",
"alg": "RS256",
"use": "sig",
"n": "zNf1a9pXkQpW3mLbV2c...",
"e": "AQAB"
}
]
}
The verification flow: kid in the token header → pick the key with the same kid from the JWKS → verify the signature. Seeing two keys is normal: **during key rotation, the new and old keys coexist.**
Operational rules that make key rotation zero-downtime:
1. OP: publish the new key to the JWKS first, start signing with it only after enough time (longer than cache TTLs) has passed, and remove the old key only after every token signed with it has expired.
2. RP: cache the JWKS (no per-request fetches), but **re-fetch once upon encountering an unknown kid**. Apply a rate limit to re-fetches to prevent DoS.
3. Reject tokens without a kid, with a kid absent from the JWKS, or with a mismatched alg.
UserInfo, Claims, and Scopes
Scope to Claim Mapping
A scope is the unit of "which bundle of claims do I want".
| scope | Standard claims unlocked |
| --- | --- |
| openid | sub (marks the request as OIDC; required) |
| profile | name, family_name, given_name, preferred_username, picture, etc. |
| email | email, email_verified |
| address | address |
| phone | phone_number, phone_number_verified |
| offline_access | requests issuance of a refresh token |
The UserInfo Endpoint
Claims can arrive inside the ID Token, or be fetched from UserInfo with the access token.
curl -s https://idp.corp.com/realms/prod/protocol/openid-connect/userinfo \
-H "Authorization: Bearer eyJhbGciOiJSUzI1NiIs..."
{
"sub": "f9a8b7c6-1234-5678-90ab-cdef12345678",
"name": "Youngju Kim",
"email": "yj.kim@corp.com",
"email_verified": true,
"groups": ["sso-admins", "developers"]
}
Caution: **always compare the sub in the UserInfo response against the sub in the ID Token.** It is the last safety net filtering token substitution attacks. Design the trade-off deliberately: defer claims to UserInfo if you want a light ID Token, or embed them in the ID Token if a gateway needs them in one pass.
state and nonce — Two Different Shields
These two easily confused parameters have clearly distinct roles.
| Aspect | state | nonce |
| --- | --- | --- |
| Defends against | CSRF (forged callbacks) | ID Token replay/injection |
| Created by | The RP | The RP |
| Returns via | Callback query parameter | A claim in the ID Token |
| Verified when | Immediately on callback | During ID Token validation |
| Without it | Attacker injects their code into the victim session (session fixation) | Replay of a stolen ID Token |
One classic attack scenario: without state, the attacker walks through the login flow with their own account, captures the callback URL (containing their code), and tricks the victim into clicking it. When the victim browser follows that callback, **the victim session is logged in as the attacker account.** Everything the victim then enters accumulates in the attacker account. A single line comparing state stops all of it.
Token Validation Checklist (for practice)
On receiving an ID Token (RP):
[ ] 1. Was it received from the TLS response of the token endpoint
(never accept via front channel)
[ ] 2. Is the alg in the JWS header on the allowlist (RS256/ES256/EdDSA)
- reject alg=none, reject mixing symmetric (HS256)
[ ] 3. Find the key by kid in the JWKS and verify the signature
[ ] 4. iss == issuer from Discovery (exact string match)
[ ] 5. aud contains my client_id; with multiple audiences, azp == my client_id
[ ] 6. exp not passed, iat within a sane range (allow 60-120s clock skew)
[ ] 7. nonce == the value stored in the session (discard after use)
[ ] 8. If re-authentication was requested, check auth_time/acr
[ ] 9. Match accounts by the iss+sub pair (never by email)
On receiving an Access Token (Resource Server):
[ ] 1. Verify the signature (if JWT) or introspect (if opaque)
[ ] 2. Validate iss and exp
[ ] 3. Validate that aud or the resource indicator is "this API"
[ ] 4. Confirm the scope permits the requested operation
[ ] 5. Apply revocation policy based on issuance time where needed
A surprising number of real-world implementations skip number 4 (iss) and number 5 (aud). "Pass if the signature is valid" means **any token from any OP passes.**
Logout — The Hard Part of OIDC
OIDC standardized three logout mechanisms.
- **RP-Initiated Logout**: the RP sends the user to end_session_endpoint to terminate the OP session.
- **Front-Channel Logout**: the OP loads the logout URL of each RP in iframes. Third-party cookie blocking has made it unreliable; it is on its way out.
- **Back-Channel Logout**: the OP POSTs a logout token (JWT) directly to the backend of each RP. The recommended option as of 2026.
To receive back-channel logout, the RP must maintain a mapping of "sid (session ID) → app session". Running sessions purely on stateless JWTs cannot satisfy this requirement — one more reason for the BFF structure of "server-side app sessions + IdP tokens kept in the backend".
OIDC in the OAuth 2.1 Era — 2026 Alignment
Mapping the OAuth 2.1 draft constraints onto an OIDC implementation:
1. **Retire patterns that received id_token over the front channel via Implicit/Hybrid** — standardize on response_type=code. Do not accept tokens in the fragment unless you have the special form_post case.
2. **PKCE everywhere** — the OIDC nonce does not make PKCE redundant. nonce blocks ID Token injection; PKCE blocks code interception. Different threats.
3. **Refresh token rotation** — enable rotation plus reuse detection for public clients. On detected reuse, revoke the whole token family.
4. **Exact redirect_uri matching** — no wildcards, no partial matches.
5. **No Bearer tokens in query strings** — headers or POST body only.
Additionally, for high-security domains consider the FAPI 2.0 Security Profile (supported as Final in Keycloak 26.6), and where secret management must be stronger, consider private_key_jwt or mTLS client authentication instead of client_secret. New Keycloak 26.6 capabilities such as the JWT Authorization Grant and federated client authentication enable passwordless client authentication in cross-service trust scenarios.
Troubleshooting Notes
| Symptom | Common cause | How to check |
| --- | --- | --- |
| invalid_grant (token exchange fails) | Code reuse, code expiry (tens of seconds), redirect_uri mismatch, verifier mismatch | Check the rejection reason in OP logs |
| Intermittent signature failures | Stale JWKS cache right after key rotation | Verify the unknown-kid re-fetch logic exists |
| Infinite login loop | Session cookie not set on callback (SameSite, domain mismatch) | Inspect Set-Cookie attributes and callback domain |
| Intermittent exp/iat errors | Clock skew between servers | NTP status, skew allowance setting |
| Frequent state mismatch errors | Multi-tab logins, short session store TTL | Store state per tab |
| Login fails only on mobile | In-app browser (WebView) blocking policies | Use the system browser (ASWebAuthenticationSession, etc.) |
Closing
OIDC in one sentence: "a protocol that standardizes authentication by layering a signed JWT (the ID Token) and automatic metadata discovery (Discovery/JWKS) on top of the OAuth 2.0 plumbing". Three practical takeaways:
- **Do not mix the roles of the three tokens.** Login is the ID Token, APIs are the Access Token, renewal is the Refresh Token. Each has a different audience and a different validator.
- **Make the validation checklist a code review standard.** If any of iss/aud/exp/nonce/signature is missing, the implementation is unfinished.
- **The OAuth 2.1 constraints (Code + PKCE only, refresh rotation, exact redirect_uri) are already common sense today.** There is no reason to wait for the draft to finalize.
If SAML upholds the trust of yesterday, OIDC is where today and tomorrow accumulate — passkeys integration, AI agent delegation, FAPI 2.0. Understand the floor of the protocol, and no change built on top of it will scare you.
References
- [OpenID Connect Core 1.0](https://openid.net/specs/openid-connect-core-1_0.html)
- [OpenID Connect Discovery 1.0](https://openid.net/specs/openid-connect-discovery-1_0.html)
- [RFC 6749 — The OAuth 2.0 Authorization Framework](https://datatracker.ietf.org/doc/html/rfc6749)
- [RFC 7636 — Proof Key for Code Exchange (PKCE)](https://datatracker.ietf.org/doc/html/rfc7636)
- [RFC 7519 — JSON Web Token (JWT)](https://datatracker.ietf.org/doc/html/rfc7519)
- [RFC 7517 — JSON Web Key (JWK)](https://datatracker.ietf.org/doc/html/rfc7517)
- [RFC 9700 — Best Current Practice for OAuth 2.0 Security](https://datatracker.ietf.org/doc/html/rfc9700)
- [RFC 9207 — OAuth 2.0 Authorization Server Issuer Identification](https://datatracker.ietf.org/doc/html/rfc9207)
- [OAuth 2.1 Draft (draft-ietf-oauth-v2-1)](https://datatracker.ietf.org/doc/draft-ietf-oauth-v2-1/)
- [OpenID Connect Back-Channel Logout 1.0](https://openid.net/specs/openid-connect-backchannel-1_0.html)
- [FAPI 2.0 Security Profile](https://openid.net/specs/fapi-2_0-security-profile.html)
- [RFC 8693 — OAuth 2.0 Token Exchange](https://datatracker.ietf.org/doc/html/rfc8693)
- [Keycloak Documentation](https://www.keycloak.org/documentation)
- [Keycloak Release Notes](https://www.keycloak.org/docs/latest/release_notes/index.html)
현재 단락 (1/256)
As of 2026, login for virtually every newly built system has converged on OpenID Connect (OIDC). Soc...