Blog
IAM Chapter 5, Part 4: Bound to This Login

On this page
Part of the IAM study series.
Part 3 proved the token is real. This part is about proving it's yours.
6. Two Random Values, Two Different Attacks
Everything in Part 3 can be true of a token that has nothing to do with the person at the browser. An ID token is a bearer artifact: if it escapes (browser history, a proxy log, a Referer header, an error report pasted into a ticket) it can be handed back inside its lifetime and will work.
noncebinds the token to this login. It's checked when the token is read, inside the signed token.statebinds the response to a flow this browser started. It's checked when the browser returns, before any token exists.
Different attacks, different moments. Having one doesn't give you the other.
nonce: one-time use, enforced by the recipient. ExpenseApp generates a random value, keeps it in John's session, and sends it in the authorization request. Entra copies it verbatim into the ID token, and ExpenseApp compares the claim against the session. A replayed token carries the nonce of the login it was minted for; that value is in nobody's current session, so it matches nothing and the token is rejected, even though it is genuine and unexpired. Entra tracks nothing and ExpenseApp keeps no list of spent tokens: the binding rides in the token and one session entry, and costs nothing.
It's mandatory in the implicit and hybrid flows and optional in code flow, where the token arrives back-channel and the window is already narrow. Optional isn't a reason to skip it.
state: the attack that runs backwards. Viggo signs in as himself, gets a code back, and doesn't use it. He sends the callback url to John. John's browser hits the callback with Viggo's code, and John is logged in as Viggo. Nothing is stolen from John; he just files his expense claim into Viggo's account. With state, the value belongs to Viggo's session, not John's, so the callback is rejected before a token is even requested.
The bug you will actually find. Both values generated, both sent, neither compared on the way back. The authorization request looks textbook. The callback handler reads the code, exchanges it, and never looks at either. It passes every test, because tests don't attack you. When you review an integration, the generation isn't the question; ask to see the comparison.
"We use PKCE, so we don't need state." It can hold (the Security BCP accepts it), but only if the code_verifier is stored in the browser session. Stored in a shared cache keyed by the code (common for load-balanced instances without sticky sessions), any browser can redeem it and PKCE gives you nothing. Ask where the verifier is stored, not whether PKCE is on. PKCE's CSRF protection is a side effect of a mechanism built for something else, and it dies quietly the day someone moves the session store, with no test failing.
7. Claims in the Token, or a Call to UserInfo
A scope in OIDC is a request for a bundle of claims. openid gets the token itself; profile adds name, given_name, family_name, locale, picture; email adds email and email_verified. There are two routes for them:
- More scopes: claims ride in the ID token. No extra call.
- UserInfo:
GET userinfo_endpointwith the access token. Current at the moment you call it, server to server. This is the one honest use of the access token by a client: presented, never opened.
| In the token | UserInfo | |
|---|---|---|
| Size | Rides on every request | Free |
| Freshness | A snapshot at issue | Current |
| Latency | None | A network call |
| Exposure | Visible wherever it travels | Server to server |
The rule: in the token goes what you need on every request and that rarely changes. UserInfo is for what you need occasionally, or that must be current.
Group claims are the classic killer. Entra stops emitting groups somewhere around 150–200 and returns an overage claim telling you to fetch them yourself. An app that silently depended on them starts behaving differently for exactly the users with the most access, which makes for a memorable afternoon.
You've met this trade-off before. Token claims vs UserInfo is Chapter 6's PDP and PIP in other clothes: attributes cached at the decision point are fast and stale; fetched live, they're current and slow. Same tension, same resolution: the cache lifetime is the staleness window, and you choose it deliberately or inherit it by accident.
And the Chapter 6 trap comes with it. If roles or groups ride in the token and the app treats them as authoritative, revoking a role does not revoke the token. That's one hour for a one-hour token, a working day for an eight-hour one. On an export-controlled resource, that window is the control you're actually operating, whatever the policy says.
"How fast does a revocation take effect?" The honest chain is: HR records it → it syncs to the directory → the next token issue picks it up → that token expires. Token lifetime is the last and smallest link. If the HR sync runs overnight, the answer is a day plus an hour, and the app team will only ever quote you their part of it.
8. Sessions and Logout: There Is Never Only One
John signs in once and three things are created together: (1) the Entra session cookie, (2) the ExpenseApp session, (3) sessions in PLM, SharePoint and the rest. After that they're completely independent, and nothing links them.
John clicks "sign out", and only (2) is destroyed. (1) is still in the browser, so the next person at that terminal opens any Skyline app, including ones John never touched, and is signed straight in as him, with no prompt.
| Mechanism | How | Works? |
|---|---|---|
| RP-initiated logout | The app redirects to end_session_endpoint, killing the Entra session too | Yes, always. Implement this. |
| Front-channel logout | The IdP loads a hidden iframe at each app's logout url | Mostly dead: depends on third-party iframe cookies, which browsers block |
| Back-channel logout | The IdP POSTs a signed logout token server-to-server to each app | Works, but every app must build an endpoint and have stored sid to find the session |
The promise you can actually make: sign-out is reliable at the identity provider and at the app the user clicked in. Everywhere else it's best effort, and when it fails there's no error: the user believes they're out, and they aren't.
So if the requirement is "access must stop now", logout is the wrong instrument. It depends on a user action and on every downstream app cooperating. Short token lifetimes and revocation are the real control (Chapter 9). Shared factory terminals get short IdP session lifetimes and forced re-authentication, not a reliance on anyone clicking sign out.
9. OIDC vs SAML, and What a Migration Really Costs
Both do the same job: an identity provider authenticates the user and hands the application a signed statement about who they are.
| SAML 2.0 | OIDC | |
|---|---|---|
| Format | XML + XML-DSig: canonicalisation, and signature-wrapping attacks where a document verifies while the app reads different content | JSON + JWT: that bug class is largely structurally impossible |
| Reach | Browser-based web apps; awkward everywhere else | Web, mobile, SPA, machine-to-machine: one protocol |
| Onboarding | Metadata + certificate exchange, agreed attribute names | One url and a client id |
| Key rotation | Certificate expiry is a scheduled outage | JWKS rotates silently |
| Authorization | None: bolt a second mechanism on beside it | Sits on OAuth, so the same login yields API access tokens |
| Still wins when | The vendor only supports SAML, and that's a very large "only" | — |
It isn't a protocol swap. Per application: re-register the client, change the code, re-map attributes (SAML attribute names are arbitrary URNs each vendor chose differently; OIDC's standard claims cover only the common ones), re-test, run both in parallel, cut over, decommission.
The constraint is never your team's capacity. It's two hundred application teams' willingness to schedule work that delivers them nothing visible.
The rule: OIDC by default for anything new. Migrate an existing SAML integration only when something else is already forcing that application open. A standalone "SAML decommissioning programme" with no other driver gets funded once, and stalls at 40%.
What Breaks in Practice
| Mistake | What goes wrong | Fix |
|---|---|---|
Generating state/nonce but never comparing | Passes every test, stops no attack | Review the comparison, not the generation |
| PKCE verifier in a shared cache | Any browser can redeem the code | Keep the verifier in the browser session |
| Depending on group claims | Overage at ~200 groups changes behaviour for power users | Fetch groups explicitly |
| Treating token roles as live | Revocation waits for token expiry | Short lifetimes on sensitive resources |
| Relying on "sign out" | The IdP session survives on shared terminals | RP-initiated logout, short IdP sessions |
| A standalone SAML-to-OIDC programme | Funded once, stalls at 40% | Migrate when the app is already open |
🔴 CHECKPOINT: Study Notes
← Previous: Who Logged In · Back to the series start · Next: Who May Do What →
