The step-up authentication series: Part 1: Step-up MFA with authentication contexts · Part 2: Defeating token replay with end-to-end step-up (you are here) · Part 3: Limiting token lifetimes in Entra ID
In Part 1 we left the finance system in good shape. The payments officer clicks Approve on a supplier invoice, Entra fires the Conditional Access policy bound to authentication context c1, the officer produces their FIDO2 key, and the API receives a token with acrs: ["c1"]. A $1.5M invoice, approved with a fresh, phishing-resistant authentication.
That token stays valid for the next fifty-nine minutes, and it binds nothing to the action. It does not say "approve invoice INV-4471 for $1.5M to this payee". It just says acrs: ["c1"]. Any invoice. Any amount. Any number of times. The next twenty approvals sail through on the same token without a single prompt, and so does anyone who gets their hands on it.
Part 1 promised a demonstration and the patterns that fix it. This article is both, developer-focused, though Entra admins will get the architecture argument too.
TLDR
- A satisfied
acrsclaim is replayable for the token's whole lifetime. Below, with SuiteAuth, a token minted to approve one invoice approves a second the user never saw. The API validates everything correctly and still pays the wrong payee. - Bearer means bearer. Whoever holds the token is the user, as far as your API can tell. Storm-0558, Midnight Blizzard, the AiTM campaigns, and CircleCI were all valid tokens in the wrong hands.
- As of June 2026, Entra ID cannot cryptographically bind an access token for a confidential web app. DPoP is unsupported, mTLS-bound tokens are unavailable for confidential clients, and Token Protection covers Windows-native sign-in sessions, not your web app's bearer tokens.
- Bind the operation into the request, and let the authorisation code be single-use for you. Seal the exact invoice, amount, payee, and approver into the OAuth
stateas an encrypted JWE, redeem the authorisation code (single-use by spec), and validateauth_timebefore you execute. You build the binding, but the non-repeatability is already in the protocol, so no database is required. "A token that can approve anything" becomes "a ceremony that approved one thing, once". - Token lifetimes, refresh tokens, sign-in frequency, and CAE are supporting controls, not the fix. Each shrinks a window. None binds the authentication to the operation. Part 3 covers every one of those levers.
The replay, demonstrated
Same line-of-business app from Part 1: a web UI, a finance API, and an Entra app registration. The API validates the token signature, audience, and that acrs contains c1 before executing an approval. Conditional Access requires FIDO2 for c1. Textbook deployment, configured exactly as the documentation says.
The payments officer approves invoice INV-4471. SuiteAuth captures the conversation: the claims request for c1, the FIDO2 prompt, the token coming back, and the approve call going out.
POST /api/invoices/INV-4471/approve HTTP/1.1
Host: finance.modern42.dev
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Imk2bEdrM0ZaenhS...The API checks the token. Signature valid. Audience correct. acrs contains c1. The response is 200 OK and the payment is queued. Exactly as designed.
Now take that same bearer token, already stepped up via the authentication context, and re-send it changing only the invoice ID. INV-9302 is a different invoice, to a different payee the officer has never seen.
POST /api/invoices/INV-9302/approve HTTP/1.1
Host: finance.modern42.dev
Authorization: Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Imk2bEdrM0ZaenhS...200 OK. No prompt. No policy evaluation. No signal that anything unusual happened.
Three calls against the same API tell the whole story. Without the step-up the API refuses; with it the API executes; and the very same token executes again for a different payee, with no prompt in between:
The gap between "this token satisfied the policy" and "this user authorised this payment" is the entire problem, and we have limited options with the token claims to fix this.
Not only attackers replay: MSAL caches the token and reuses it for every approval in the next hour, so the replay is built into normal operation.
Bearer tokens are bearer tokens
The OAuth term for a token your API accepts on sight is a bearer token, and the name is the threat model. Whoever bears it, is it. Your API cannot ask how the caller came to hold it, and recent incident history is a catalogue of valid tokens in the wrong hands:
- Storm-0558 (July 2023). A China-based actor acquired a Microsoft signing key and forged tokens Exchange Online accepted, reading mail across roughly 25 organisations including US government departments (Microsoft's analysis).
- Midnight Blizzard (January 2024). Password spray into a non-production tenant, then abuse of OAuth applications to pivot into Microsoft corporate mailboxes.
- AiTM phishing, the current default. Adversary-in-the-middle kits proxy the real sign-in page, let the user complete real MFA, and walk away with the tokens. Token theft was 31% of Microsoft 365 breaches in 2025, and Microsoft documented a multi-stage AiTM campaign ending in token compromise as recently as May 2026.
- CircleCI (January 2023). An infostealer on one engineer's laptop stole a valid, 2FA-backed SSO session and the actor exfiltrated customer secrets.
Every one walks straight past our finance API's validation. The token is real, the claims are satisfied, the $1.5M endpoint pays. So the first design question is not "how do we validate harder", but "who can reach the token at all".
Public clients, confidential clients, and where tokens live
OAuth's two client types are two different security models, not two configuration options.
A public client (SPA, mobile, or desktop app) runs entirely in territory the user controls. Microsoft is blunt:
"Public clients, which include native applications and single page apps, must not use secrets or certificates when redeeming an authorization code." — Microsoft Learn: OAuth 2.0 authorization code flow
No secret means the client cannot prove it is the client, and the access token ends up in the browser, in JavaScript memory or session storage, within reach of any XSS payload, malicious extension, or infostealer running there.
A confidential client (a server-side web app) holds a client secret or certificate and redeems the code on the server. The access token is minted to the server and never touches the browser, which holds one credential: an HttpOnly session cookie scoped to your app.
The formalised version is the Backend-for-Frontend (BFF) pattern (OAuth 2.0 for Browser-Based Apps): the confidential client runs the code flow, stores tokens server-side, and issues only a session cookie.
Be honest about what this buys. An AiTM proxy in front of a BFF still captures something, but it is a session cookie to your application, governed by your lifetime and your revocation, not a resource token it can take straight to the API. An infostealer finds no token in browser storage because there is none. This is an architecture decision, not a portal checkbox: if your high-risk operations live in a SPA today, that SPA cannot give you the guarantees that follow.
Why not just cryptographically bind the token to the client and keep the SPA? The right question, frustrating answer: as of June 2026 Entra has no generally available way to sender-constrain an access token for a web app. No DPoP, no mTLS-bound tokens for confidential clients, and Token Protection covers native apps, not browsers (Part 3 covers what it does do). So a stolen bearer token is replayable, full stop, and the design goal is that there is nothing worth stealing.
Why the obvious dials don't fix it
Every team that sees the replay reaches for the same dials: shorter token lifetimes, dropping refresh tokens, sign-in frequency Every time, CAE, max_age. Each shrinks a window, and Part 3 is dedicated to configuring them well. But every one constrains when a token works, never what it is for: a token minted to approve a $50 expense looks identical to one minted to approve a $5M transfer. Now we fix that.
Designing for non-repeatability
Strip the problem to one question: in the whole OAuth conversation, what can only happen once?
Not the access token, it is bearer and replayable. Not the session, it lives for days. Two things can, and one of them is yours to create.
The authorisation code is single-use, and Entra enforces it. Not best-effort guidance, a MUST in the spec:
"The client MUST NOT use the authorization code more than once. If an authorization code is used more than once, the authorization server MUST deny the request and SHOULD revoke (when possible) all tokens previously issued based on that authorization code." — RFC 6749, §4.1.2
Entra's codes live for roughly one minute, and a redeemed or expired code returns invalid_grant. So one leg is already non-repeatable, for free.
The state parameter can carry the whole bound transaction. A correction we make in most design reviews: state is not part of the token. You attach it to the authorisation request, Entra echoes it back on the redirect to your callback, and that is the whole journey. The spec gives state one job, CSRF protection (RFC 6749 §10.12). But RFC 9700 now tells every client on the authorisation code flow to defend against code injection with PKCE (or, for OpenID Connect, the nonce). With that handled elsewhere, state is free to carry the operation itself: not a random lookup key, but the invoice, amount, payee, and the one user whose ceremony counts, sealed into a single opaque string only the server can open. Sealing it as a JWE, encrypted and integrity-protected, honours Microsoft's "don't put sensitive data in state" guidance while letting the request describe itself.
So state carries what, and the single-use authorisation code supplies once. Together, the flow needs no server-side state at all:
- When the user initiates a high-risk operation, the server gathers what is being approved: invoice, amount, payee, the
oidof the only user whose ceremony will count, and an issued-at timestamp. - It seals all of that, plus the PKCE verifier, into a JWE: encrypted, integrity-protected, short expiry. That string goes out as
state. Nothing is written to a database. - When the callback arrives, the server opens the seal. Tampered, forged, or expired, and it will not decrypt, so the request stops. A valid seal hands back the exact transaction, no lookup.
- The server redeems the authorisation code. This is the single-use gate, the protocol's job, not yours. A replayed callback presents an already-redeemed code and Entra answers
invalid_grant. - The server validates the fresh token against the sealed transaction (right user, right context, authenticated after the seal was minted), executes that one operation, and discards the token.
Notice where the guarantee lives. Single-use is the authorisation code's, granted by the spec and enforced by Entra: you do not build it, and a "simplified" callback cannot quietly drop it. What you build is the binding, the seal that says this ceremony was for this amount to this payee, plus the checks that confirm it. Persist it in a database row for an audit trail if you like, but the sealed state is the same guarantee with no table to garbage-collect.
The build
The cast: a server-side finance app (any stack with a server, we use TypeScript), an Entra Web app registration with a client secret or certificate (not a SPA), the c1 authentication context from Part 1 on a Conditional Access policy requiring phishing-resistant MFA, and sign-in frequency Every time on that policy. The API's app registration opts acrs in as an optional claim on access tokens (Part 1), per token type, so check the one you validate.
The whole flow, on the wire:
There is no table to create. The transaction lives inside the seal, so the only setup is a key and a helper that mints it, using jose:
import { EncryptJWT, jwtDecrypt } from "jose";
// 32 bytes from your secret store, never in source. Rotate it like any signing key.
const SEAL_KEY = await loadSealKey(); // Uint8Array(32)
type TransactionSeal = {
invoice: string; // INV-4471
amountCents: number;
payeeId: string;
oid: string; // the only user whose ceremony counts
pkce: string; // PKCE verifier, sealed so it survives the round trip
};
// dir + A256GCM is authenticated encryption: only the holder of SEAL_KEY can mint
// a token that decrypts, so a valid seal is proof we issued it. Nest a JWS inside
// if you want a separate signature, but for a server talking to itself this is enough.
function sealTransaction(t: TransactionSeal): Promise<string> {
return new EncryptJWT(t)
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
.setIssuer("finance.modern42.dev")
.setAudience("transaction-callback")
.setIssuedAt()
.setExpirationTime("5m") // the binding self-destructs; no row to expire
.encrypt(SEAL_KEY);
}Initiating a transaction. Note what is sealed before the user goes anywhere near Entra: the exact invoice, amount, payee, and the approver oid.
// POST /transactions (session-authenticated, CSRF-protected like any state-changing route)
app.post("/transactions", requireSession, async (req, res) => {
const invoice = await invoices.get(req.body.invoiceId);
const pkceVerifier = generatePkceVerifier(); // RFC 7636
// Everything the callback will need is sealed into one opaque string.
// No transactionId, no insert, no row to look up later.
const state = await sealTransaction({
invoice: invoice.id,
amountCents: invoice.amountCents,
payeeId: invoice.payeeId,
oid: req.session.oid,
pkce: pkceVerifier,
});
const authorizeUrl = new URL(
"https://login.microsoftonline.com/" + TENANT_ID + "/oauth2/v2.0/authorize",
);
authorizeUrl.search = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: "https://finance.modern42.dev/auth/transaction-callback",
scope: "api://modern42-finance/.default",
claims: JSON.stringify({
access_token: { acrs: { essential: true, value: "c1" } },
}),
prompt: "login",
state, // the sealed transaction, encrypted end to end
code_challenge: pkceChallenge(pkceVerifier),
code_challenge_method: "S256",
}).toString();
res.redirect(303, authorizeUrl.toString());
});Now the callback splits in two, and the split is the point. The first handler opens the seal and shows the user exactly what they are about to authorise, before any token is redeemed. In a popup it reads as a clean "you are approving $1.5M to Acme Pty Ltd" dialog, decoded from the seal we minted: what-you-see-is-what-you-sign at the application layer:
// GET /auth/transaction-callback (registered redirect URI; runs in the popup)
app.get("/auth/transaction-callback", requireSession, async (req, res) => {
const { code, state } = req.query;
// Open the seal. Tampered, forged, or expired -> jwtDecrypt throws. No DB read:
// the transaction details travel inside the token.
let seal;
try {
({ payload: seal } = await jwtDecrypt(state, SEAL_KEY, {
issuer: "finance.modern42.dev",
audience: "transaction-callback",
}));
} catch {
return res.status(409).send("Unknown, tampered, or expired transaction");
}
if (seal.oid !== req.session.oid) {
return res.status(403).send("Transaction belongs to a different user");
}
// Show what they are about to sign, decoded from the seal, before we spend the code.
res.send(renderConfirm({
code, state, // carried back on the confirm POST; the code is inert without our secret
invoice: seal.invoice,
amount: formatMoney(seal.amountCents),
payee: seal.payeeId,
}));
});The second handler runs only when the user confirms. Here the code is redeemed, and single-use is enforced by Entra for free:
// POST /auth/transaction-confirm (the user pressed "Yes, approve")
app.post("/auth/transaction-confirm", requireSession, async (req, res) => {
const { code, state } = req.body;
const { payload: seal } = await jwtDecrypt(state, SEAL_KEY, {
issuer: "finance.modern42.dev",
audience: "transaction-callback",
});
if (seal.oid !== req.session.oid) {
return res.status(403).send("Transaction belongs to a different user");
}
// 1. Redeem the code. This is the single-use gate, and Entra enforces it: a
// replayed confirm hits an already-redeemed code and gets invalid_grant.
// The secret and the PKCE verifier never leave the server.
const tokens = await redeemAuthorizationCode({
code,
codeVerifier: seal.pkce,
clientSecret: CLIENT_SECRET,
redirectUri: "https://finance.modern42.dev/auth/transaction-callback",
});
const claims = await validateJwt(tokens.access_token); // signature, iss, aud, tid, exp
// 2. The proof checks: this user, this context, authenticated after the seal was minted.
if (claims.oid !== seal.oid) {
return res.status(403).send("token subject is not the approver");
}
if (!claims.acrs?.includes("c1")) {
return res.status(403).send("authentication context not satisfied");
}
if (claims.auth_time < seal.iat - CLOCK_SKEW_SEC) {
// A stale auth_time means no fresh ceremony happened: an opportunistic
// acrs grant or a replayed session. Reject it.
return res.status(403).send("authentication predates this transaction");
}
// 3. Execute the one operation this ceremony authorised. Then forget the token.
// No offline_access was requested, nothing is cached, nothing survives.
await payments.execute(seal.invoice, seal.amountCents, seal.payeeId);
res.redirect(303, "/invoices/" + seal.invoice + "/receipt");
});A few deliberate choices worth calling out:
prompt=loginplus sign-in frequency Every time, notmax_age. OIDC'smax_age("re-authenticate if the last one is older than this") has documented quirks in Entra and no crisp statement of semantics, so do not hang a payments control on it. The dependable stack is the CA policy onc1carrying Every time,prompt=loginas belt and braces, and the server validatingauth_timeagainst the seal'siat. Testmax_agein your own tenant first.- The
auth_timecheck kills opportunistic evaluation. Part 1 covered how Entra populatesacrsopportunistically when the policy is already satisfied, and such a token carries an oldauth_time. Rejecting any authentication that predates the transaction means a token that merely looks satisfied fails. Entra only addsacrswhile sign-in frequency is still satisfied, so Every time closes the same door from Entra's side. - No
offline_accesson this leg. No refresh token exists to silently re-mint a satisfied token, so the proof is short-lived and non-renewable by construction. Part 3 covers the blunt, tenant-wide version. - The approve endpoint is gone. There is no
POST /api/invoices/{id}/approveaccepting bearer tokens. Execution happens server-side, after the user confirms, from the sealed transaction. Low-risk reads keep their normal bearer-token API as in Part 1. Only the high-risk operations move. - Confirm before you redeem. The first callback shows the operator the exact amount and payee before the code is spent, the what-you-see-is-what-you-sign step Entra's own prompt skips. Entra's codes are short-lived (roughly a minute), so confirmation has to be prompt. If you need a longer pause, flip the order: redeem immediately, hold the token server-side, and gate
payments.executeon the confirm. Single-use still comes from the code either way.
Now every replay angle bounces. The validation checklist, all five rows, or you have a different article's problem:
| Check | Mechanism | What skipping it costs you |
|---|---|---|
| Token is genuine | Signature, iss, aud, tid, exp | You are not even validating tokens |
| The right human | oid equals the seal's oid | Anyone's ceremony approves anyone's payment |
| The step-up happened | acrs contains the required context | Plain sign-in tokens satisfy your payments flow |
| It happened now | auth_time is not before the seal's iat (minus skew) | Opportunistic acrs and replayed sessions pass |
| It happens once | The authorisation code is single-use, enforced by Entra | A replayed confirm redeems a used code: invalid_grant |
What this still doesn't solve
We tell clients this before they sign off:
- The step-up prompt still does not show the amount. Entra's ceremony shows a sign-in prompt for the application, not "approve $1.5M to Acme Pty Ltd". Our confirm-before-redeem screen puts the amount and payee in front of the user, but it is our UI making that promise, not the authenticator, and an attacker who fully controls the session and page could still misrepresent it. The complete fix is what-you-see-is-what-you-sign inside the authenticator, which Entra does not offer. Keep alerting on approvals the approver did not initiate from a recognised flow.
- Server compromise or key theft ends the game. The sealing key, client secret, and execution path all live on the server, so an attacker with code execution there does not need to replay anything. Going stateless concentrates the crown jewels into two secrets,
SEAL_KEY(which forges valid seals) and the client secret (which redeems codes). Keep both in a managed secret store or HSM, rotate them, and alert on use. This pattern defends the front door, not the vault. - The session still initiates transactions. A hijacked session cannot complete a ceremony, but it can start them and spam your approver with FIDO2 prompts. Rate-limit creation and alert on volume. And if Entra ships DPoP or mTLS-bound tokens for confidential clients, parts of this design get simpler. Until then, architecture is the control.
Which pattern for which operation
The full transaction-bound flow has a friction tax: one ceremony per operation. Correct for a $1.5M invoice, absurd for a $50 expense. The ladder we use on engagements:
| Risk tier | Examples | Pattern |
|---|---|---|
| Routine | Read reports, raise a $50 expense | Baseline MFA at sign-in. No step-up. |
| Sensitive | Export customer data, change payee bank details, activate a PIM role | Authentication context with acrs validation (Part 1), sign-in frequency Every time on the policy |
| High-risk, repeatable harm | Payment approval, irreversible admin operations, bulk data movement | Everything in this article: confidential custody, a sealed transaction in state, single-use authorisation code, auth_time validation, confirm-before-redeem |
One sizing gut-check: count the high-risk operations per approver per day. If it is two hundred, a ceremony each is unworkable, so change what a transaction is. Approve a payment run, not a payment. One ceremony, one seal, one batch.
Mandatory plug
This is the kind of work we do at Modern 42: pressure-testing the gap between "the policy is satisfied" and "the user actually authorised this", then engineering the flow that closes it. If you have a payments path, privileged admin surface, or data-export feature riding on bearer tokens and an acrs check, get in touch. And if you want to capture and replay your own OAuth conversations like SuiteAuth does above, the closed beta is open for sign-up.
Part 3 covers the supporting cast: every lever Entra ID gives you to limit how long any token stays dangerous. Read it next.




