Skip to content
Hameed's dev blog

Blog

IAM Chapter 5, Part 3: Who Logged In

·8 min read·---
Peacock feather eye.jpg
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 tokenID token
Addressed toThe Expenses API (aud: api://expenses)ExpenseApp itself (aud: 7f3a-expenseapp)
What ExpenseApp doesCarries it: never opens it, never reads a claim from itOpens it: never forwards it, never sends it anywhere
FormatNot defined at all: JWT, opaque string, anythingAlways 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.

ClaimMeans
subThe subject: a stable, never-reused id for the user (objectGUID again)
issWho issued it
audWho it is for: the client id
exp / iatExpires at / issued at
auth_timeWhen they actually authenticated
amrHow: password, MFA, certificate
nonceWhich 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

CheckAgainstStops
1. SignatureThe issuer's public keyForgery, tampering
2. issThe configured issuer, exact stringA token from the wrong provider
3. audYour own client idToken substitution
4. expNow, with a small clock skewReplay 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:expenses

and 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 modeWhat happensRule
Caches the key set foreverBehaves exactly like the pasted key: broken at the next rotation until a human restarts itUnknown kid → re-fetch before rejecting
Never cachesThe IdP takes every app's full request volume; a JWKS outage becomes a total authentication outageCache for hours, not milliseconds
Re-fetches with no rate limitAn attacker sends tokens with random kids and turns your app into a load generator aimed at the IdPOne 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

MistakeWhat goes wrongFix
Verifying only the signatureSubstituted tokens are acceptedAll four checks, especially aud
Reading identity from the access tokenBreaks when the API's format changesRead identity from the ID token
Pasting the signing key into appsNobody ever rotatesFetch from JWKS
Caching JWKS foreverOutage at the next rotationRe-fetch on unknown kid
Hand-building the issuer stringTrailing-slash mismatchTake it from discovery, compare exactly
Disabling TLS verificationThe whole trust chain is handed awayNever; fix the proxy instead
/common without tenant pinningEvery work account on earth can sign inPin the allowed tenants

🔴 CHECKPOINT: Study Notes

IAM Chapter 5 Part 3: Who Logged In, handwritten study notes on the OIDC ID token, the four checks, JWKS key rotation and discovery

← Previous: OAuth 2.0, Part 2 · Back to the series start · Next: Bound to This Login →

Notes from a technical product leader on product management, identity & access management, and agentic AI — from 12 years of building enterprise software.