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"]. The $1.5M invoice is approved with a fresh, phishing-resistant authentication. Job done.
If only that was the case. That token is still valid for the next fifty-nine minutes. An unfortunate bathroom break and a unlocked screen could be devastating.
The issued token doesn't bind the action to the step up assurance. E.g. it doesn't 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 else who gets their hands on it.
Part 1 promised we would demonstrate this first-hand and then show you the patterns that actually fix it. This article is that demonstration, and the how to guide. It is the most requested follow-up I've have ever had.
This article will be more developer focused but that doesn't mean Entra Admins won't get anything out of this.
TLDR
- A satisfied
acrsclaim is replayable for the token's entire lifetime. We demonstrate it below with SuiteAuth: the token minted for one invoice approval approves a second invoice the user never saw. The API validates everything correctly and still pays the wrong person. - 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 not supported, mTLS-bound tokens are not available 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 code be single-use for you. Seal the exact invoice, amount, payee, and approver into the OAuth
stateas an encrypted, self-contained token (a JWE), then on the way back redeem the authorisation code — which Entra makes single-use by spec — and validateauth_timebefore you execute. "A token that can approve anything" becomes "a ceremony that approved one thing, once". No database required, though you can add one. - The single-use property comes from the authorisation code, not from your storage.
stateis opaque to Entra, so you seal the binding into it yourself, but the non-repeatability is already in the protocol. The server's job is to verify the seal and the claims, then act exactly 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. We cover every one of those levers in Part 3.
The replay, demonstrated
The setup is the 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, the audience, and that acrs contains c1 before it executes an approval. Conditional Access requires FIDO2 for c1. This is the textbook deployment, configured the way the documentation says to configure it.
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.
We take the exact same bearer token (which has been stepped to the level of assurance we need via the Authentication Cntext) and re-send the request, changing only the invoice ID. INV-9302 is a different invoice, to a different payee, that the officer has never laid eyes on.
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 anywhere that anything unusual happened.
he gap between "this token satisfied the policy" and "this user authorised this payment" is the entire problem, and no amount of token validation closes it.
Worth being precise about who replays. The attacker is the dramatic case, but there are legitimate UI replays too. MSAL caches the token and reuses it for every approval in the next hour, because that is what a sensible token cache does. So what can we do?
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 has no way to ask how the caller came to hold the token, and the 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 that Exchange Online accepted, reading mail across roughly 25 organisations including US government departments. Microsoft's own analysis is worth your time.
- Midnight Blizzard (January 2024). Password spray into a non-production tenant, then abuse of OAuth applications to pivot into Microsoft corporate mailboxes. Valid tokens, malicious holder.
- 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 resulting tokens. Token theft accounted for 31% of Microsoft 365 breaches in 2025, and Microsoft documented a multi-stage AiTM campaign ending in token compromise as recently as May 2026. The MFA claims are already inside the stolen token.
- CircleCI (January 2023). An infostealer on one engineer's laptop stole a valid, 2FA-backed SSO session and the actor exfiltrated customer secrets. The canonical "stolen session replays straight past MFA" story.
Every one of these walks straight past the validation logic in our finance API. 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". It is "who can reach the token at all".
Public clients, confidential clients, and where tokens live
OAuth has two* client types, and they are two different security models, not two configuration options.
A public client (a SPA, a mobile app, a desktop app) runs entirely in territory the user controls. Microsoft is blunt about the consequence:
"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. More importantly for us, the access token ends up in the browser. JavaScript memory, session storage, wherever your framework puts it. Everything that runs in that browser, an XSS payload, a malicious extension, an infostealer on the endpoint, has the same reach the application does.
A confidential client (a server-side web app) holds a client secret or certificate and redeems the authorisation code on the server. The access token is minted to the server and never touches the browser. The browser holds exactly one credential: an HttpOnly session cookie, scoped to your app, with whatever session controls you choose.
The formalised version of this is the Backend-for-Frontend (BFF) pattern from the IETF's OAuth 2.0 for Browser-Based Apps best current practice. The BFF acts as the confidential OAuth client, runs the code flow, stores tokens server-side, and issues only a session cookie to the browser. The browser never sees an access or refresh token.
Be honest about what this buys. An AiTM proxy in front of a BFF still captures something, but it captures a session cookie to your application, governed by your session lifetime and your revocation, not a resource access token it can take straight to the API. An infostealer finds no token in browser storage because there is none. The high-value artefact has moved to a place the user's machine cannot reach.
This is an architecture decision. There is no checkbox in the Entra portal that moves your tokens server-side. If your high-risk operations live in a SPA today, the honest version of this article is that the SPA cannot give you the guarantees that follow.
And it would be reasonable to ask: why not just cryptographically bind the token to the client, and keep the SPA? That is the right question with a frustrating answer. DPoP (RFC 9449) is not supported by Entra ID. mTLS-bound access tokens for confidential clients are not available. Token Protection in Conditional Access binds the Windows sign-in session token for native apps, and Microsoft's own documentation states browser-based applications are not supported. As of June 2026 there is no generally available way to sender-constrain an Entra access token for a web app. We cover what Token Protection actually does in Part 3.
For this article, the consequence is simple: a stolen bearer token is replayable, full stop, so 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, and each one closes a different window while leaving the actual gap open:
- Shorter access token lifetimes shrink the replay window to as little as ten minutes. A ten-minute window on a payments endpoint is still twenty payments.
- Dropping refresh tokens (omitting
offline_access) stops Entra silently re-minting satisfied tokens. It does nothing about replay inside the current token's lifetime. - Sign-in frequency "Every time" guarantees a fresh prompt when the session is re-evaluated, with a documented five-minute grace floor. It cannot tie that prompt to a particular invoice.
- CAE revokes tokens in near-real time for first-party Microsoft resources. Your custom finance API is not one of those unless you build CAE support into it.
max_ageandauth_timeget you closer to "authenticated recently", and we useauth_timebelow. But recent is not the same as "for this operation", and Entra'smax_agehandling has quirks we will be honest about.
These levers all matter, and configuring them well is genuinely worth doing, which is why Part 3 is dedicated to them. But notice what every one of them has in common. They constrain when a token works. None of them constrains what it is for. A token freshly minted to approve a $50 expense still looks identical to one minted to approve a $5M transfer. Part 1 ended on exactly that observation. Now we fix it.
Designing for non-repeatability
Strip the problem down to one question: in the whole OAuth conversation, what can only happen once?
Not the access token. Bearer, replayable, we just proved it. Not the user's session. It lives for days. Two things, it turns out, and one of them is yours to create:
The authorisation code is single-use, and Entra enforces it. This is not best-effort guidance, it is 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 also live for roughly one minute. A code that has been redeemed, or has expired, returns invalid_grant. So one leg of the conversation is already non-repeatable, for free. Yay!
The state parameter can carry the whole bound transaction. First, a correction we end up making in most design reviews: state is not part of the token. You attach it to the authorisation request, Entra echoes it straight back on the redirect to your callback, and that is the whole of its journey. The token never carries it. The spec gives state one job, CSRF protection (RFC 6749 §10.12). But RFC 9700, the OAuth 2.0 Security Best Current Practice, now tells every client on the authorisation code flow to defend against code injection with PKCE — or, for OpenID Connect, the nonce — confidential clients included. With that defence handled elsewhere, state no longer has to be your anti-forgery token, which frees it to do something more useful: carry the operation itself. Not a random lookup key into a database, but the invoice, the amount, the payee, and the one user whose ceremony counts — sealed into a single opaque string that only the server can open. Microsoft's guidance is "don't put sensitive data in state", and sealing it as a JWE — encrypted and integrity-protected — honours that while letting the request describe itself. Keep the payload small so the encoded state stays short.
So state carries what the ceremony is for. But you still need once, and that is the part you don't have to build — because the authorisation code above already is single-use. Put the two together and the flow needs no server-side state at all:
- When the user initiates a high-risk operation, the server gathers what is being approved: the invoice, the amount, the payee, the
oidof the only user whose ceremony will count, and an issued-at timestamp. - It seals all of that — along with the PKCE verifier — into a JWE: encrypted, integrity-protected, stamped with a short expiry. That sealed string goes out as
stateon the authorisation request. Nothing is written to a database. - When the callback arrives, the server opens the seal. If it was tampered with, forged, or has expired, it will not decrypt and the request stops there. A valid seal hands back the exact transaction the server minted, with no lookup.
- The server redeems the authorisation code. This is the single-use gate, and it is the protocol's job, not yours: a replayed callback presents an already-redeemed code and Entra answers
invalid_grant. One code, one redemption, one execution. - The server validates the freshly minted 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 now. 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. You can persist that binding in a database row instead, and some teams will, for audit trails or idempotency keys. But the sealed state is the same guarantee with no table to create, index, or garbage-collect. The seal is the easiest version; the ledger is the same idea with more moving parts.
The build
The cast: a server-side finance app (any stack with a server will do, we use TypeScript here), an Entra Web app registration with a client secret or certificate (not a SPA registration), the c1 authentication context from Part 1 bound to a Conditional Access policy requiring phishing-resistant MFA, and sign-in frequency Every time set on that policy. The API's app registration has acrs opted in as an optional claim on access tokens, exactly as covered in Part 1. Each token type opts in individually, 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. We use jose, the standard JOSE library for TypeScript:
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, the exact amount, the exact payee, and the oid of the only user whose ceremony will count.
// 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 a single token is redeemed. In a popup it reads as a clean "you are approving $1.5M to Acme Pty Ltd" dialog, and because the amount and payee are decoded from the seal we minted, this is 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. This is where the code is redeemed — and where 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 definesmax_ageas "force re-authentication if the last one is older than this", and Entra does process it, but its behaviour has documented quirks and no crisp Microsoft statement of exact semantics. Do not hang a payments control on it. The dependable stack is: the CA policy onc1carries sign-in frequency Every time (so Entra genuinely prompts on each interactive trip),prompt=loginas belt and braces, and the server validatingauth_timeagainst the seal's issued-at (iat) as the check that the ceremony really happened. If you wantmax_ageas a third layer, test it in your own tenant first.- The
auth_timecheck is what kills opportunistic evaluation. Part 1 covered how Entra populatesacrsopportunistically when the underlying policy is already satisfied, with no prompt. An opportunistically satisfied token carries an oldauth_time. Our check rejects any authentication that predates the transaction, so a token that merely looks satisfied fails. This is also documented behaviour working in our favour: Entra only addsacrsopportunistically when the policy's sign-in frequency is still satisfied, so Every time on the policy andauth_timevalidation on the server close the same door from both sides. - No
offline_accesson this leg. No refresh token exists that could silently re-mint a satisfied token. The proof is short-lived and non-renewable by construction. This is the sharp version of the "remove refresh tokens" advice, applied to one flow instead of the whole tenant. Part 3 covers the blunt version. - The approve endpoint is gone. In this architecture 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 and routine operations keep their normal bearer-token API exactly as in Part 1. Only the high-risk operations move. - No database on the hot path. The transaction lives in the sealed
stateand single-use lives in the authorisation code, so there is nothing to insert, look up, or garbage-collect, and nothing to leave behind. If you want a persisted record — an audit trail, an idempotency key, an explicit consumed-jtiset for defence in depth — add a table alongside. It is a useful addition, not a requirement for the security property, and it is exactly the database-backed variant we mentioned: same guarantee, more moving parts. - Confirm before you redeem. The first callback decrypts the seal and shows the operator the exact amount and payee before the code is ever spent — the what-you-see-is-what-you-sign step Entra's own prompt skips. Honest caveat: Entra's authorisation codes are short-lived (roughly a minute, as we measured earlier), so the confirmation has to be prompt. If you need a longer human 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 replay it. Every angle bounces:
And the validation checklist, because this is the part that gets cargo-culted incompletely. 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 we borrowed from the banks
None of this is novel, which should give you confidence in it. Regulated finance has had a name for "bind the authentication to the exact transaction" since 2018. PSD2's dynamic linking requirement says the authentication code must be:
"specific to the amount of the payment transaction and the payee agreed to by the payer." — EBA, PSD2 RTS Article 5 Q&A
What you see is what you sign. Our sealed state is dynamic linking on a budget: the binding between ceremony and amount-plus-payee travels in an encrypted token we mint and verify, rather than in a bank-grade signed authentication code, because Entra does not sign transaction payloads. The Entra ceremony proves who and when. The seal pins what.
The formal high-assurance version is the OpenID Foundation's FAPI 2.0 Security Profile, final as of February 2025, built on sender-constrained tokens and Pushed Authorization Requests, and offering exactly the "fine-grained and transactional authorization" and "replay detection" properties we are approximating. If you operate under a regulator that names FAPI, that profile is your target, and you should know Entra ID is not the authorisation server that gets you there today.
Closer to home, Microsoft itself ships this pattern. Protected actions in Entra bind authentication contexts to sensitive admin operations, enforced "at the time the user attempts to perform the protected action and not during user sign-in" per the documentation. When Microsoft needed step-up for operations that matter, they bound it to the operation, not the session. So should you.
What this still doesn't solve
We tell clients this list before they sign off on the design, so it belongs in the article too:
- The step-up prompt itself 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 does put the amount and payee in front of the user, decoded from the seal, which is a genuine improvement — but it is our UI making that promise, not the authenticator. An attacker who fully controls the session and the page we render could still misrepresent what is being approved. 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 ends the game. The sealing key, the client secret, and the execution path all live on the server. An attacker with code execution there does not need to replay anything. This pattern defends the front door, not the vault. Standard server-side controls still apply.
- Whoever holds the keys can mint or redeem. Going stateless moves the crown jewels from a database row to two secrets:
SEAL_KEY, which forges valid transaction seals, and the client secret, which redeems codes. Keep both in a managed secret store or HSM, rotate them, and alert on use. It is the same instruction as "guard the ledger", with the trust relocated into the key material. - 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 transaction creation and alert on volume.
- No sender-constrained tokens, still. If Entra ships DPoP or mTLS-bound tokens for confidential clients, parts of this design get simpler and some of the custody argument relaxes. Watch this space. Until then, architecture is the control.
Which pattern for which operation
The full transaction-bound flow has a real friction tax: one ceremony per operation, by design. That is correct for a $1.5M invoice and 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 the answer is two hundred, a ceremony each is unworkable, and the design answer is to change what a transaction is. Approve a payment run, not a payment. One ceremony, one seal, one batch. The pattern holds, the unit of binding moves.
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, a privileged admin surface, or a data-export feature riding on bearer tokens and an acrs check, get in touch and we will walk through where your replay windows are. And if you want to capture and replay your own OAuth conversations the way SuiteAuth does in the screenshots above, the closed beta is open for waitlist sign-up.
Part 3 covers the supporting cast we deliberately skimmed here: every lever Entra ID gives you to limit how long any token stays dangerous. Read it next.




