Skip to content
Hameed's dev blog

Blog

IAM Chapter 5, Part 4: Bound to This Login

·9 min read·---
Spider web.jpg
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.

  • nonce binds the token to this login. It's checked when the token is read, inside the signed token.
  • state binds 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_endpoint with 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 tokenUserInfo
SizeRides on every requestFree
FreshnessA snapshot at issueCurrent
LatencyNoneA network call
ExposureVisible wherever it travelsServer 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.

MechanismHowWorks?
RP-initiated logoutThe app redirects to end_session_endpoint, killing the Entra session tooYes, always. Implement this.
Front-channel logoutThe IdP loads a hidden iframe at each app's logout urlMostly dead: depends on third-party iframe cookies, which browsers block
Back-channel logoutThe IdP POSTs a signed logout token server-to-server to each appWorks, 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.0OIDC
FormatXML + XML-DSig: canonicalisation, and signature-wrapping attacks where a document verifies while the app reads different contentJSON + JWT: that bug class is largely structurally impossible
ReachBrowser-based web apps; awkward everywhere elseWeb, mobile, SPA, machine-to-machine: one protocol
OnboardingMetadata + certificate exchange, agreed attribute namesOne url and a client id
Key rotationCertificate expiry is a scheduled outageJWKS rotates silently
AuthorizationNone: bolt a second mechanism on beside itSits on OAuth, so the same login yields API access tokens
Still wins whenThe 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

MistakeWhat goes wrongFix
Generating state/nonce but never comparingPasses every test, stops no attackReview the comparison, not the generation
PKCE verifier in a shared cacheAny browser can redeem the codeKeep the verifier in the browser session
Depending on group claimsOverage at ~200 groups changes behaviour for power usersFetch groups explicitly
Treating token roles as liveRevocation waits for token expiryShort lifetimes on sensitive resources
Relying on "sign out"The IdP session survives on shared terminalsRP-initiated logout, short IdP sessions
A standalone SAML-to-OIDC programmeFunded once, stalls at 40%Migrate when the app is already open

🔴 CHECKPOINT: Study Notes

IAM Chapter 5 Part 4: Bound to This Login, handwritten study notes on nonce and state, UserInfo, sessions and logout, and OIDC vs SAML

← Previous: Who Logged In · Back to the series start · Next: Who May Do What →

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