OpenID Connect: ID tokens, discovery and logout
Generated by Claude (Fable 5.1) by hand from the identity and integration ops spike, Scout learning center on 2026-10-14, reviewed by a second reader (Claude Fable 5.1), Scout learning center on 2026-10-14.
Concept: Single sign-on with SAML and OpenID Connect · also covers OAuth 2.x, Identity and access · Lesson 4 of 10 in Identity and integration ops · version 1
Practice thisOn this page
OAuth 2.0 hands a client a token to call an API; it says nothing about who logged in. OpenID Connect is the identity layer on top of it: the ID token (a JWT about the authentication event), discovery (one well-known document that names every endpoint and the signing keys), the nonce that binds an ID token to one request, the userinfo endpoint and the standard claims, the iss parameter on the way back, and the three logouts (RP-initiated, front-channel, back-channel). This is the protocol behind 'Sign in with your company account' for every modern customer, and the lesson whose practice is the learning center's OIDC test run: authorization code with PKCE against a local Keycloak and a local node-oidc-provider, with the ID token validated against the provider's JWKS and the tampered and wrong-audience cases rejected.
Read more
- guideOpenID Foundation: How OpenID Connect Works (openid.net) checked 2026-10-14
- guideOkta: OAuth 2.0 and OpenID Connect overview (developer.okta.com) checked 2026-10-14
- docMicrosoft Entra: OpenID Connect on the Microsoft identity platform (learn.microsoft.com) checked 2026-10-14
How it is done in practice
When a customer says 'we use Okta' or 'we are on Entra', the implementation engineer asks for the issuer URL, fetches its discovery document, and has the authorization, token, userinfo, JWKS and end-session endpoints in one request. They register the app with the customer's administrator (client id, exact redirect URI, which claims the app needs), run the authorization code flow once with a script to see a real ID token, and read its claims next to the provider's claims reference to decide the mapping: which claim becomes the user's id (sub, never email), which gives the display name, whether groups arrive in the token or need a call to a directory API. The validator pins the algorithm, checks iss, aud, exp and nonce, and refetches the JWKS on an unknown kid. Then they decide logout: usually RP-initiated logout with id_token_hint so the provider session ends too, and back-channel logout when the customer wants a sign-out in the provider to end the app's sessions. Every one of those decisions goes in the integration document with the discovery URL and the client id, not the secret.
Technologies
Diagram
sequenceDiagram participant B as Browser participant RP as Relying party (your app) participant OP as OpenID provider RP->>OP: GET /.well-known/openid-configuration (once, cached) OP-->>RP: endpoints, jwks_uri, issuer RP->>B: redirect /authorize scope=openid profile email, state, nonce, code_challenge B->>OP: login and consent OP->>B: 302 redirect_uri?code&state&iss B->>RP: callback: check state and iss RP->>OP: POST /token code + code_verifier OP-->>RP: id_token, access_token, refresh_token RP->>OP: GET jwks_uri (on unknown kid) RP->>RP: validate id_token: signature, iss, aud, exp, nonce RP->>OP: GET /userinfo Bearer access_token RP->>B: Set-Cookie app session; later: redirect end_session_endpoint?id_token_hint
sequenceDiagram participant B as Browser participant RP as Relying party (your app) participant OP as OpenID provider RP->>OP: GET /.well-known/openid-configuration (once, cached) OP-->>RP: endpoints, jwks_uri, issuer RP->>B: redirect /authorize scope=openid profile email, state, nonce, code_challenge B->>OP: login and consent OP->>B: 302 redirect_uri?code&state&iss B->>RP: callback: check state and iss RP->>OP: POST /token code + code_verifier OP-->>RP: id_token, access_token, refresh_token RP->>OP: GET jwks_uri (on unknown kid) RP->>RP: validate id_token: signature, iss, aud, exp, nonce RP->>OP: GET /userinfo Bearer access_token RP->>B: Set-Cookie app session; later: redirect end_session_endpoint?id_token_hint
Code
sh# The local provider the practice uses (bound to 127.0.0.1 only; choose your own admin password)
docker run -d --name learn-keycloak --memory 1g -p 127.0.0.1:18080:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin -e KC_BOOTSTRAP_ADMIN_PASSWORD="$KC_ADMIN_PASSWORD" \
quay.io/keycloak/keycloak:26.4 start-dev
curl -s http://127.0.0.1:18080/realms/master/.well-known/openid-configuration | head -c 300
python# The relying party's side of the login, trimmed from the learning center's test script
import secrets, requests, jwt
from jwt import PyJWKClient
disc = requests.get(ISSUER + "/.well-known/openid-configuration").json()
state, nonce = secrets.token_urlsafe(16), secrets.token_urlsafe(16)
# 1. send the browser to disc["authorization_endpoint"] with response_type=code, client_id, redirect_uri,
# scope="openid profile email", state, nonce, code_challenge (S256) ... the browser returns code and state
# 2. exchange the code (public client: code_verifier, no secret)
tok = requests.post(disc["token_endpoint"], data={"grant_type": "authorization_code", "code": code,
"redirect_uri": REDIRECT_URI, "client_id": CLIENT_ID, "code_verifier": verifier}).json()
# 3. validate the ID token: kid lookup in the JWKS, pinned algorithm, iss, aud, exp, then nonce
key = PyJWKClient(disc["jwks_uri"]).get_signing_key_from_jwt(tok["id_token"])
claims = jwt.decode(tok["id_token"], key.key, algorithms=["RS256"], audience=CLIENT_ID, issuer=disc["issuer"],
options={"require": ["exp", "iat", "iss", "aud", "sub"]})
assert claims["nonce"] == nonce
# 4. userinfo with the access token (Keycloak: only for tokens that carry the openid scope)
me = requests.get(disc["userinfo_endpoint"], headers={"Authorization": "Bearer " + tok["access_token"]}).json()
# {'sub': ..., 'email': 'alice@example.test', 'groups': ['integration-engineers'], ...}
javascript// The no-Docker provider: panva/node-oidc-provider on 127.0.0.1:19000 (dev interactions, PKCE required).
// The package is ESM-only: require() of it needs Node 22.12 or later; on older Node use import.
const Provider = require("oidc-provider").default || require("oidc-provider");
const oidc = new Provider("http://127.0.0.1:19000", {
clients: [{ client_id: "web-pkce", token_endpoint_auth_method: "none",
redirect_uris: ["http://127.0.0.1:18081/callback"], grant_types: ["authorization_code", "refresh_token"], response_types: ["code"] }],
features: { devInteractions: { enabled: true }, introspection: { enabled: true }, revocation: { enabled: true } },
pkce: { methods: ["S256"], required: () => true },
findAccount: async (ctx, id) => ({ accountId: id, claims: async () => ({ sub: id, email: `${id}@example.test`, email_verified: true }) }),
});
oidc.listen(19000, "127.0.0.1");
Subtopics
The ID token and the access token
Two tokens, two audiences
An OpenID Connect login returns two tokens with different jobs. The ID token is a JWT addressed to the client (aud is the client id) that states who authenticated, when, how, and at which provider; the client validates it and reads sub to know the user. The access token is addressed to a resource server (userinfo, or an API) and the client should treat it as opaque, even when it happens to be a JWT; the API validates it, the client does not. Using an ID token to call an API is a mistake (its audience is wrong and APIs should reject it) and so is reading identity out of an access token in the client (its format is the API's business). The Okta overview puts the two side by side; the Entra ID token claims reference shows how much an ID token can carry.
What is in an ID token
iss, sub, aud, exp and iat are required; nonce when the request sent one; auth_time, acr and amr describe when and how the user authenticated (amr tells you whether MFA happened); azp names the client when aud lists several. Then the claims the requested scopes unlock: profile (name, preferred_username, picture), email and email_verified, and anything a mapper adds, such as groups. The test run's ID token from Keycloak carried iss, aud web-pkce, sub, email alice@example.test, groups integration-engineers and the nonce; node-oidc-provider's carried the same core claims with sub alice.
Discovery and the JWKS
One document to rule the configuration
Every OpenID provider publishes its configuration at issuer plus /.well-known/openid-configuration: the issuer (which must equal the document's own base, and which every ID token's iss must match), authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri, end_session_endpoint, the supported scopes, response types, grant types, claims, signing algorithms, code challenge methods and client authentication methods. A client configured with only the issuer URL can derive everything else, and a client that hard-codes endpoints breaks when the provider moves them. Keycloak's issuer is the realm URL; Entra's is https://login.microsoftonline.com/<tenant>/v2.0; Okta's is the org URL or a custom authorization server under it.
Keys and how a client uses them
jwks_uri points at the JSON Web Key Set with the provider's public signing keys, each with a kid. The client's JWT library fetches it, caches it, finds the key by the ID token's kid, and refetches when a kid is unknown, which is what makes key rotation invisible. Comparing two providers' discovery documents (the practice asks for Keycloak against node-oidc-provider) is a fast way to learn which features are optional: one lists a device endpoint, the other a revocation endpoint, one supports more signing algorithms, one lists claims_supported and the other does not.
The authorization request, nonce and the iss parameter
Authorization code with PKCE, plus two things
An OpenID Connect login is the OAuth authorization code flow with the openid scope and, for a code flow, a nonce: a random value the client generates, keeps in the session, and sends as a parameter; the provider puts it in the ID token, and the client checks it matches, so an ID token obtained in one context cannot be replayed into another. state protects the redirect, PKCE protects the code, nonce protects the ID token; all three travel in the same request. The scopes decide which claims come back (openid is required; profile and email are the usual two; offline_access asks for a refresh token). prompt, max_age, login_hint and acr_values let the client ask for a fresh login, a particular user, or a stronger authentication.
The iss response parameter
RFC 9207 adds iss to the redirect back so a client that talks to several providers can check that the code arrived from the one it sent the user to, which stops a mix-up attack where a malicious provider gets a code meant for another. node-oidc-provider sent it in the learning center's run (the redirect was callback?code=...&state=...&iss=http://127.0.0.1:19000); the discovery document advertises it as authorization_response_iss_parameter_supported. A client should compare it when present.
ID token validation
The checklist in OpenID Connect Core 3.1.3.7
The relying party validates the ID token before trusting anything in it: the signature with the key found by kid in the provider's JWKS, using an algorithm the client accepts (RS256 by default; the spec allows the client to require others); iss equals the issuer from discovery; aud contains the client id, and if it contains others, azp is present and equals the client id; exp is in the future and iat is recent enough for the client's policy; nonce equals the value sent; and, if the client asked for a maximum authentication age, auth_time is recent enough. A token that fails any check is not a login.
What the proof did
The learning center's client did exactly this with PyJWT: header read (alg RS256, kid), key found through PyJWKClient at the discovery document's jwks_uri, decode with algorithms pinned, audience set to web-pkce, issuer set to the discovery issuer and a require list, then a nonce comparison. It printed the validated claims, then showed a token with one changed payload byte rejected on the signature and the genuine token rejected when presented with the wrong audience. The token lesson has the same checklist from the verifier's side; this one is the relying party's.
Userinfo, standard claims and claim mapping
The userinfo endpoint
With the access token, the client may call userinfo and get the claims the scopes unlocked, as JSON (or a signed JWT if the client registered for that). It is the place to fetch claims that are too big or too changeable for the ID token, and it is what the Postman and Bruno collections in the API testing lesson call as 'the protected resource'. Keycloak serves it only to tokens that carry the openid scope: the learning center's first Newman run got 403 from a client-credentials token until the token request asked for scope=openid, which is the kind of provider rule that costs an afternoon when you do not know it.
Mapping claims to your user model
The standard claims (sub, name, given_name, family_name, preferred_username, email, email_verified, picture, locale, updated_at) are the common ground; everything else is provider-specific and configured by the customer's administrator through mappers (Keycloak), claim customisation (Entra) or a custom authorization server's claims (Okta). The rules an engineer applies: key the user record on iss plus sub, never on email (emails change and can be reassigned); treat email as verified only when email_verified is true; ask for a groups claim when the app needs roles and decide what to do when the list is too long (Entra emits a groups overage marker beyond a limit and expects a directory call instead); document every claim the app reads so the customer can see what they are disclosing.
Logout, three ways
RP-initiated, front-channel, back-channel
Logging out of an app does not log out of the provider, and customers notice when the next login happens without a password prompt. RP-initiated logout sends the browser to the provider's end_session_endpoint with id_token_hint (so the provider knows which session) and post_logout_redirect_uri (registered in advance; Keycloak calls them valid post logout redirect URIs); the provider ends its session and sends the browser back. Front-channel logout is the provider loading a logout URL of every app in hidden iframes during its own logout, which browsers' third-party cookie rules increasingly break. Back-channel logout is the provider posting a signed logout token (a JWT with sub or sid and a logout event claim) directly to each app's registered back-channel endpoint, which works without the browser and is what to implement when the customer wants 'sign out everywhere'.
What to decide for an integration
At minimum implement RP-initiated logout with id_token_hint so a user who clicks 'sign out' is signed out of the provider as well, and register the post-logout redirect URI exactly. Implement back-channel logout when the customer's security team asks for provider-initiated sign-out or deprovisioning to end live sessions; it means storing the sid from the ID token with the app session so the logout token can find it. Say in the integration document which of the three the app supports; the Okta and Entra lesson shows where each is configured on the provider side.
Provider quirks the test run found
Three Keycloak facts worth knowing before the first run
Keycloak serves userinfo only to tokens carrying the openid scope (a client-credentials token without it gets 403, and the fix is to ask for scope=openid). Keycloak detaches the client's session from the user session when an authorization code is reused (RFC 6749 says the server should revoke what it issued for that code), so a test that replays a code must run after the tests that use that client's tokens. Keycloak's cookies are Secure with SameSite=None; a browser logs in on plain http to 127.0.0.1 because that is a secure context, while a scripted client has to clear the Secure flag (dev only) or use https. Its issuer is the realm URL, its admin REST API can create the realm, the user, the clients and the mappers in one script, and its JWKS and SAML descriptor are both one GET away.
node-oidc-provider, and reading the differences
node-oidc-provider requires PKCE by default (it refused a request without one before the login page), sends the iss parameter on the redirect, issues a refresh token only when configured to, and runs in process with no container. Its discovery document is smaller than Keycloak's. Listing the differences between two providers' discovery documents, and between a provider's behaviour and the specification's optional features, is the habit that keeps an integration from assuming the customer's provider behaves like the one you tested on.
Sources
- guideOpenID Foundation: How OpenID Connect Works (openid.net) checked 2026-10-14
- guideOkta: OAuth 2.0 and OpenID Connect overview (developer.okta.com) checked 2026-10-14
- docMicrosoft Entra: OpenID Connect on the Microsoft identity platform (learn.microsoft.com) checked 2026-10-14
- docMicrosoft Entra: ID token claims reference (learn.microsoft.com) checked 2026-10-14
- docKeycloak: Getting started with Docker (www.keycloak.org) checked 2026-10-14
- docAuthlib (GitHub README): OAuth and OpenID Connect clients and servers for Python (github.com) checked 2026-10-14
- specOpenID Connect Core 1.0 (openid.net) checked 2026-10-14
- specOpenID Connect Discovery 1.0 (openid.net) checked 2026-10-14
- specOpenID Connect RP-Initiated Logout 1.0 (openid.net) checked 2026-10-14
- specOpenID Connect Back-Channel Logout 1.0 (openid.net) checked 2026-10-14
- specRFC 9207: OAuth 2.0 Authorization Server Issuer Identification (datatracker.ietf.org) checked 2026-10-14
- docnode-oidc-provider documentation (github.com) checked 2026-10-14