alice signs in to a print shop with her photos.example account — OpenID Connect as OAuth2 plus one token, the ID token and the checks the app runs on it, discovery and JWKS, the ID token refused at the API, sign-in and photo access in one consent, single sign-on, and a key rotation.
The flow, step by step
- Once, at startup: prints.example reads photos.example's discovery document, then its public keys.
- alice clicks Sign in with photos.example at prints.example.
- prints.example redirects her browser to photos.example's
/authorize, as in OAuth2, with
scope=openid profile email and a nonce. - alice signs in at photos.example and allows prints.example to know who she is.
- photos.example redirects her browser back to prints.example with a one-time
code. - prints.example trades the code at
/token, server to server, and gets two tokens: an access token and
an ID token. - prints.example checks the ID token: the signature with the key its
kid names, then iss, aud, exp
and nonce. - prints.example signs alice in with a session of its own. It knows her by
iss and sub.
Steps 2 to 5 are OAuth2's authorization code flow, unchanged. Steps 0, 6 and 7 are what OIDC adds.
OAuth2, plus one token
OpenID Connect (OIDC) is a layer on OAuth2: the same flow, and this time alice logs in to prints.example using
her photos.example account.
- In OAuth2, the token was for something photos.example holds. photos.example gave prints.example an access
token to alice's photos, and prints.example used it at photos.example's API.
- In OIDC, the token is for prints.example's own door. photos.example gives prints.example an ID token,
a signed statement addressed to prints.example: "this is alice, she just signed in here".
- prints.example checks the statement and lets alice in on it.
- alice has no account and no password at prints.example.
- Who gets in stays prints.example's decision; photos.example only says who she is.
- The answer at
/token carries both tokens. The access token is still a bearer token for the API, as in
OAuth2. - What OIDC adds to OAuth2:
- the
openid scope, which turns the request into an OIDC one; - the ID token, beside the access token in the answer at
/token; - the
nonce, which ties the ID token to one sign-in; - UserInfo, a standard API returning the user's profile;
- discovery and JWKS: the provider describes itself and publishes its keys at fixed addresses.
Reading the drawing
- Step plays eight scenes, one message per step.
- The parties sit where they sat on the OAuth2 page: alice's browser over prints.example on the left,
photos.example over its API on the right, the wire between them.
- prints.example's card shows its sessions, the last ID token with its claims, what it read from the
discovery document, and the keys it holds.
- photos.example's card shows the codes, the consents, alice's account and its key pairs, each public key
as a PEM.
- The buttons in the browser's page and on the app's card take the story our own way; the scene picker
or Restart brings it back.
The roles, renamed
OIDC renames OAuth2's roles after what each party does in a login.
| OAuth2 role | OIDC role | In the story |
|---|
| Resource owner | End-user | alice, in her browser |
| Client | Relying party | prints.example, which relies on photos.example to say who alice is |
| Authorization server | OpenID Provider | photos.example, which signs alice in and issues both tokens |
| Resource server | Resource server | api.photos.example, which serves /userinfo and the albums |
- prints.example holds no password and no account. Its session records who signed in, from the ID token.
- photos.example holds alice's account. It is the only party that ever sees her password.
The ID token
The ID token is a JWT signed by photos.example and addressed to prints.example. It says who signed in, where,
when and for which app.
| Claim | In the story | What it says |
|---|
iss | https://photos.example | who issued it |
sub | 2048163 | who signed in: an id that never changes at this issuer |
aud | prints-app | which app it is for: the app's client_id |
exp, iat | an hour apart | when it was issued, and until when it may be accepted |
auth_time | when alice typed her password | when she last signed in at photos.example |
nonce | the value from /authorize | which sign-in it answers |
name, email | from profile and email | her profile, as the scopes allowed |
- The token is real ES256. The app's card shows the last ID token whole: one click selects it, and
jwt.io
verifies it with the PEM on photos.example's card.
iss and sub together name the person. An app links its own records to that pair, never to the
email address, which a user can change and another provider can claim.
The checks prints.example runs
prints.example accepts the ID token only after five checks, listed on its card at the step it answers.
- The signature: the header's
kid names a key from photos.example's JWKS, and the signature verifies
with it. iss is exactly the issuer from the discovery document.aud is prints.example's own client_id. A genuine ID token issued to another app is refused.exp is in the future.nonce is the one this session sent to /authorize. The app made it, kept it in the session, and
photos.example copied it into the token.
After the checks, OIDC is done: prints.example signs alice in with its own session cookie, as in
Cookies and Sessions
. The ID token is not a session; it is the proof
the session was opened on.
Discovery and JWKS
prints.example is configured with one address, the issuer's, and reads everything else from photos.example.
- The discovery document is at a fixed path,
/.well-known/openid-configuration, on the issuer's address.
It lists the endpoints (authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri) and what
the provider supports. - The JWKS (JSON Web Key Set), at
jwks_uri, lists the provider's public keys, each with its kid. - The app reads both at startup, in the first scene, and keeps the keys.
- A token's header names its key by
kid. The app verifies only with a key from the jwks_uri it
already knows, never with one a token carries.
The ID token at the API
The access token is a bearer token for the API; the ID token is not, and the API refuses it.
/userinfo is an API, on api.photos.example. It took no part in the sign-in and keeps no list of tokens.- With the ID token (the scene The ID token at the API), the API finds a genuine token: the signature
verifies and
iss is photos.example. But aud is prints-app, not the API, so it answers
401 Unauthorized. - With the access token from the same sign-in,
aud is the API and the scope holds openid: the API
answers with alice's profile, to whoever holds the token, as in
OAuth2
. - The rule: the ID token stays with the app that received it; only the access token goes to an API.
Sign-in and access in one consent
One redirect can ask for both: who alice is, and access to her photos.
- Signed in, alice clicks Import from photos.example, and prints.example asks for
scope=openid photos.read. - She is already signed in at photos.example, so it asks only for what she has not allowed yet: her photos.
- The answer holds both tokens again. prints.example checks that the new ID token's
sub is the person
already signed in, then reads the albums with the access token.
Single sign-on and signing out
Signing out of prints.example ends prints.example's session, not photos.example's.
- alice clicks Sign out: prints.example forgets who is in its session.
- She clicks Sign in with photos.example again. Her
psid cookie still signs her in at photos.example,
and she allowed prints.example before, so photos.example sends a code at once: no password, no consent page. - That is single sign-on: one sign-in at the provider serves every app that relies on it.
- Ending photos.example's session too takes a separate request to the provider (RP-initiated logout), which
the page leaves out.
Key rotation
A provider changes its signing key from time to time, and keeps publishing the old one while tokens it signed
may still be in use.
- In the last scene photos.example starts signing with
photos-2027, and its JWKS lists both keys. - alice signs in again. The new ID token's header says
kid: photos-2027, which prints.example does not hold. - prints.example fetches the JWKS again from its
jwks_uri, finds the key, and the checks pass.
The course
Authentication and Secrets
runs the same flow against
Keycloak in its chapters An identity provider of your own and OIDC.