Blog
IAM Chapter 5, Part 3: Who Logged In

On this page
Part of the IAM study series.
The gap: Part 2 ended with ExpenseApp holding an access token that opens the expenses API. It still has no idea who is sitting at the browser. OAuth got the app permission; it never said whose permission it was. OpenID Connect (OIDC) closes that gap.
1. The ID Token: A Second Token, a Different Addressee
One token-endpoint response now returns two tokens, and they are addressed to different parties:
| Access token | ID token | |
|---|---|---|
| Addressed to | The Expenses API (aud: api://expenses) | ExpenseApp itself (aud: 7f3a-expenseapp) |
| What ExpenseApp does | Carries it: never opens it, never reads a claim from it | Opens it: never forwards it, never sends it anywhere |
| Format | Not defined at all: JWT, opaque string, anything | Always a JWT, with mandated core claims |
An ID token is a signed statement from the identity provider saying who authenticated, when, and how, addressed to one application and read by no one else. One token carries a contract; the other carries none. That asymmetry settles a lot of arguments.
| Claim | Means |
|---|---|
sub | The subject: a stable, never-reused id for the user (objectGUID again) |
iss | Who issued it |
aud | Who it is for: the client id |
exp / iat | Expires at / issued at |
auth_time | When they actually authenticated |
amr | How: password, MFA, certificate |
nonce | Which login this belongs to (Part 4) |
auth_time and amr have no equivalent anywhere else. Session timeouts and step-up authentication are impossible without them.
2. Four Checks, or the Token Is Worthless
| Check | Against | Stops |
|---|---|---|
| 1. Signature | The issuer's public key | Forgery, tampering |
2. iss | The configured issuer, exact string | A token from the wrong provider |
3. aud | Your own client id | Token substitution |
4. exp | Now, with a small clock skew | Replay of an old token |
The attack the third check stops: John signs in to QuickTools, which now holds a real ID token with aud: quicktools. QuickTools replays it to ExpenseApp. If ExpenseApp skips the aud check, the signature is valid, the issuer is valid, the expiry is valid, everything is genuine, and QuickTools is now logged in as John, everywhere. With the check, it's one string comparison: "this says quicktools, I am expenseapp", rejected.
The signature is the check that always passes. A substituted token is authentic: genuinely issued, genuinely untampered. A team that verifies the signature and nothing else will tell you, with confidence, that they validate their tokens. Ask which four.
A token does not defend itself. The signature proves origin and integrity. Every other claim is descriptive: the issuer writing down the intended conditions of use and trusting the recipient to honour them. It's a sealed letter marked FOR THE EXPENSES API; the seal is unforgeable, and it does nothing to stop anyone opening it.
3. Where It Comes From: One Word
There is no OIDC flow. It's the authorization code flow from Part 2, unchanged: same redirect, same code, same back-channel exchange. One value changes:
scope=openid read:expensesand the token response grows one field, id_token, beside the access_token that was already there. OIDC is a profile of OAuth: it takes an existing standard, adds required behaviour and new fields, and changes nothing that exists.
Three consequences matter:
- Every OAuth library gets OIDC nearly free. A server that does OAuth adds token issuance, not a second stack. That's a large part of why OIDC displaced SAML so fast.
- Every Part 2 protection still applies. PKCE, exact redirect-URI matching, client authentication, one-time use of the code. OIDC adds no flow-level defences because it changed no flow, so sloppy redirect validation is just as fatal.
- One endpoint serves both. No
openid, plain OAuth. With it, OIDC. By design, not by hack.
"We'll just read the name from the access token." It works, because Entra puts name in there, and that's the problem. The access token's format is an agreement between the authorization server and the API; the client isn't a party to it and gets no stability promise. It breaks on a day nobody deployed your app, and the real cost is the authorization check someone writes on it six months later.
4. The Key, and Why Rotation Is the Hard Part
Distributed: the signing key is pasted into every app. The day it changes, all 200 apps reject every token at the same second with "invalid signature". So nobody rotates. The key is six years old, on build servers, in backups, on the laptops of people who left. If it leaks, anyone can mint a token for anyone, for any app, and nothing logs it. This is SAML certificate expiry again.
Published: the provider serves a key set at its JWKS url, and apps fetch it. The token header names which key signed it ({"alg": "RS256", "kid": "b2n-4Qp7"}), and old and new keys live in the set at once. Rotation becomes publish new → sign with new → wait out the old → drop the old, and nobody is told, because nothing needs coordinating.
| Failure mode | What happens | Rule |
|---|---|---|
| Caches the key set forever | Behaves exactly like the pasted key: broken at the next rotation until a human restarts it | Unknown kid → re-fetch before rejecting |
| Never caches | The IdP takes every app's full request volume; a JWKS outage becomes a total authentication outage | Cache for hours, not milliseconds |
| Re-fetches with no rate limit | An attacker sends tokens with random kids and turns your app into a load generator aimed at the IdP | One fetch in flight, short minimum interval |
Design for the issuer you cannot control. An acquired supplier's vendor may swap keys with no advance publishing. Whether you survive that is decided entirely by whether you re-fetch on an unknown kid. Their discipline is their choice; yours is yours.
5. Discovery: One String Instead of Four
Every app needs the authorization endpoint, the token endpoint, the JWKS url, and the exact issuer string. That's four values times 200 apps, and a wrong one never announces itself as a wrong one. A mistyped JWKS url surfaces as invalid signature; a wrong issuer as token rejected. Neither points at configuration.
The provider publishes all of it at a path fixed by the spec: the issuer url + /.well-known/openid-configuration. (.well-known is a reserved prefix meaning "machine-readable metadata about this server lives here"; OIDC didn't invent it, it just uses it.)
So Charon configures one value, the issuer url, and everything else is discovered. This, more than any security property, is why OIDC displaced SAML: onboarding went from a metadata-and-certificate ritual to pasting a url. At 200 applications that's the difference between a programme and a wish.
iss is compared as an exact string, not a url. A trailing slash costs an afternoon. Take it from the discovery document; never normalise it, never build it yourself. An issuer identifier is a name, not an address. Move the hostname and you've created a different issuer, and no redirect can save you, because nothing in HTTP reaches into a string comparison.
Follow the trust backwards. The signature verifies because this public key is Entra's, because the JWKS at that url served it, because the discovery document said so, because it came over HTTPS and the certificate validated. There's nothing underneath that. A verify=False or rejectUnauthorized: false, added on a Tuesday to get past a dev proxy, hands the whole chain away: a substituted discovery document points jwks_uri at keys the attacker controls, and every check then passes.
The multi-tenant trap. Entra's /common endpoint is for apps that accept any Microsoft organisation. Configured against it without pinning tenants, an app authenticates anyone with any work account anywhere, and every token verifies, because Microsoft genuinely issued them all. The signature was never the question; the issuer was.
What Breaks in Practice
| Mistake | What goes wrong | Fix |
|---|---|---|
| Verifying only the signature | Substituted tokens are accepted | All four checks, especially aud |
| Reading identity from the access token | Breaks when the API's format changes | Read identity from the ID token |
| Pasting the signing key into apps | Nobody ever rotates | Fetch from JWKS |
| Caching JWKS forever | Outage at the next rotation | Re-fetch on unknown kid |
| Hand-building the issuer string | Trailing-slash mismatch | Take it from discovery, compare exactly |
| Disabling TLS verification | The whole trust chain is handed away | Never; fix the proxy instead |
/common without tenant pinning | Every work account on earth can sign in | Pin the allowed tenants |
🔴 CHECKPOINT: Study Notes
← Previous: OAuth 2.0, Part 2 · Back to the series start · Next: Bound to This Login →
