We keep seeing this on Conditional Access reviews. A finance system handles everything from journal entries to multi-million-dollar payment approvals. The team is rightly worried about the payments path, so they reach for Conditional Access Sign-In Frequency, set it to one hour, scope it to All Users on the policy targeting the app, and call the job done. Step-up MFA, sorted.
Except it isn't, and the cost is high. Hundreds of clerks who only ever post read-only reports now pay the friction tax of an MFA prompt every hour. The payments approver, the one user the policy was actually written to protect, still has a fifty-nine-minute window after every reauth where a stolen token works fine. Everyone loses friction. Nobody actually gains step-up.
What the team needed was an authentication control bound to the act of approving a payment, not to the calendar. That is what Conditional Access authentication contexts are. Sign in once with normal MFA, do your low-risk work without prompts, and the moment a user clicks Approve Payment the application asks Entra for a fresh step-up authentication, just-in-time, exactly for that action, with the strength you've defined. The hundreds of low-risk users keep their flow. The handful of high-risk ones get challenged at the moment it actually matters.
This is Part 1 of a two-part series on step-up MFA in Entra Conditional Access. In this post we cover what authentication contexts are, how the protocol works on the wire, how to wire it into your own line-of-business app, and where the model genuinely runs out of road. Part 2 will tackle the deeper problem, token replay against high-risk operations, and show why a satisfied authentication context isn't always enough on its own.
If you only remember one thing from this article: authentication contexts let you enforce Conditional Access at the operation level, not just at the application level. They are the most precise lever you have for protecting sensitive actions inside an application without punishing every other interaction.
Key takeaways
- Sign-In Frequency on All Users is not step-up MFA. It applies friction uniformly to every user of an app, leaves replay windows open for the high-risk users you actually wanted to protect, and was never designed to bind reauth to a specific action. Reach for it for session lifetime controls, not for protecting Approve Payment.
- A Conditional Access authentication context is a named requirement (IDs
c1throughc99) that an application can demand at the moment of a sensitive operation. - A Conditional Access policy bound to that context defines what satisfies it: MFA, FIDO2, compliant device, terms of use, or any combination.
- When the application demands a context the user hasn't yet satisfied, the UI redirects to
login.microsoftonline.comwith a claims request asking for that context. Entra evaluates the policy, prompts the user if needed, and returns a token containing the satisfied context in theacrsclaim. - For simple line-of-business apps, the UI can know upfront which contexts an operation needs and redirect without involving the API in any back-and-forth. The 401 + WWW-Authenticate claims challenge protocol exists for cases where the API is the source of truth, but it isn't mandatory.
- Authentication contexts don't fully solve token replay. For truly high-risk operations like large payments, you need more than a satisfied
acrsclaim. We'll explain why, and Part 2 of this series will demonstrate it first-hand.
Authentication contexts in 60 seconds
A Conditional Access authentication context is a label that represents an authentication requirement. The label itself is just an ID, c1, c2, through to c99, plus a display name and description. What gives the label meaning is a Conditional Access policy that says "when a user requests a token carrying this context, enforce these controls."
This separation matters. Pre-authentication-contexts, Conditional Access was binary at the application level. Either everyone hitting SharePoint Online got MFA on every page, or nobody did. There was no in-between. Authentication contexts move enforcement off the application and onto named requirements that the application can demand whenever it needs to.
A SharePoint site holding the staff lunch menu and a SharePoint site holding M&A documents aren't the same risk. Approving a $50 expense and approving a $5 million transfer aren't the same risk. Forcing identical friction across both produces MFA fatigue on the low-risk paths and pushes admins to weaken MFA broadly so day-to-day work stays usable. Authentication contexts let you keep day-to-day light and only escalate when something serious is happening.
We use this pattern heavily on engagements: PIM role activation, sensitivity-labelled SharePoint sites, custom line-of-business APIs with privileged operations, and Microsoft Defender for Cloud Apps session policies that step users up when they try to download labelled content. Microsoft's authentication context documentation is the canonical reference if you want the official framing.
What this looks like in the Entra admin centre
If you're an Entra admin, the setup is a two-step job:
- Create the context. Go to Entra admin centre → Protection → Conditional Access → Authentication context → New authentication context. Give it an ID (
c1throughc99), a display name like Approve Payment, a description, and tick Publish to apps so downstream apps can see and consume it. - Bind a Conditional Access policy. Under Conditional Access → Policies → New policy, set Target resources to Authentication context and pick the one you just created. Configure the grant control like any other policy, Require MFA, Require authentication strength (FIDO2, phishing-resistant), Require compliant device, or any combination.
You can also drive both steps via Microsoft Graph if you prefer code. A one-liner to list every authentication context already in your tenant:
Connect-MgGraph -Scopes "AuthenticationContext.Read.All"
Get-MgIdentityConditionalAccessAuthenticationContextClassReference |
Select-Object Id, DisplayName, IsAvailableIf IsAvailable is False, the context is hidden from apps. Toggle Publish to apps when you're ready for them to consume it.
How step-up authentication works at the protocol level
If you're an Entra admin and not the one writing the application code, knowing the shape of this conversation is still worth your time. Most authentication context issues that land on an admin's desk turn out to be one of three things: the application asking for the wrong context, the application not asking for any context at all, or the resource API not receiving the satisfied claim. Each of those has a tell on the wire.
In the simplest form, step-up authentication via authentication contexts is a four-step conversation between three parties:
The client (a web app, a single-page app, a mobile app, a desktop app) wants to call a sensitive endpoint that requires authentication context c1. It checks the current access token. If acrs doesn't include c1, the UI redirects the user to Entra ID's /authorize endpoint with an additional parameter:
GET https://login.microsoftonline.com/{tenant}/oauth2/v2.0/authorize
?client_id=11112222-bbbb-3333-cccc-4444dddd5555
&response_type=code
&scope=api%3A%2F%2Fcontoso-finance%2F.default
&claims=%7B%22access_token%22%3A%7B%22acrs%22%3A%7B%22essential%22%3Atrue%2C%22value%22%3A%22c1%22%7D%7D%7D
&state=...&code_challenge=...&code_challenge_method=S256That claims parameter, once URL-decoded, is a standard OpenID Connect claims request:
{"access_token":{"acrs":{"essential":true,"value":"c1"}}}In plain English: "I need an access token that has acrs equal to c1, and this is essential." Entra looks up the Conditional Access policy bound to c1, decides what the user needs to satisfy, and prompts them. If the policy requires FIDO2, the user sees a security-key prompt even if they signed in earlier with a password. Once satisfied, Entra issues a fresh authorisation code, the client exchanges it at the token endpoint, and the new access token contains acrs: ["c1"]. The client calls the API with that token. The API checks that acrs contains c1 and serves the request.
That's the entire pattern. The API's job is to validate that the token has the auth context it needs. Everything else, who prompted who, which credentials were used, what policy fired, is between the client, the user, and Entra.
A note on API-to-UI communication
Where teams trip on the Microsoft docs: The official tutorials lead with the 401 + WWW-Authenticate + claims-challenge protocol, because that's the most general API-to-UI conversation. Teams reading those samples often assume the whole 401 dance is mandatory. It isn't. For most line-of-business apps where the UI knows what each action needs, you can ask Entra for the right context upfront and skip the back-and-forth entirely. The simpler path is not explicit in the docs, so it is hard to cut through all the complexity to see it.
To make the contrast concrete, the protocol the docs lead with looks like this on the wire:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="",
authorization_uri="https://login.microsoftonline.com/common/oauth2/authorize",
error="insufficient_claims",
claims="eyJhY2Nlc3NfdG9rZW4iOnsiYWNycyI6eyJlc3NlbnRpYWwiOnRydWUsInZhbHVlIjoiYzEifX19",
cc_type="authcontext"Microsoft built this convention so that an API can tell a client exactly what claims it needs without the client having to know in advance. The client decodes the claims parameter, hands it to its MSAL library, and the library does the right thing. This is genuinely useful when the API is the source of truth on what every operation requires, and when you have many clients hitting the same API that shouldn't need to maintain their own copy of the operation-to-context mapping. For a deep dive on the wire format, the cp1 client capability, and the difference between an auth context challenge and a CAE revocation, Microsoft's claims challenges documentation is the canonical reference.
But if you're building a simple LOB application where the same team owns the UI and the API, the UI can check the requirements for a given action before calling the API and redirect to Entra directly:
async function approvePayment(transferId: string) {
const token = await msal.acquireTokenSilent({
scopes: ["api://modern42-finance/.default"],
claims: JSON.stringify({
access_token: { acrs: { essential: true, value: "c1" } }
}),
});
return fetch(`/api/transfers/${transferId}/approve`, {
method: "POST",
headers: { Authorization: `Bearer ${token.accessToken}` },
});
}The UI knows that approvePayment requires c1. It asks MSAL for a token that satisfies that requirement. MSAL checks the cache, finds the current token doesn't have acrs: ["c1"], and triggers an interactive sign-in with the claims request. The user gets prompted, the new token comes back, and the API call goes out with a token that already satisfies the requirement. The API just does normal token validation. No 401, no claims challenge header, no special handling.
The complexity in most authentication context tutorials is the song-and-dance of API-to-UI communication. The actual core idea is much smaller: the UI asks Entra for a token with the right claims, and the API checks that the token has them. That's it.
What's in the token after a step-up
You don't need to write code to inspect what's in a token. Grab one from a sign-in trace (Microsoft Edge DevTools, Fiddler, or your favourite browser network tab), paste it into jwt.ms, and the claims appear in plain JSON. This is genuinely useful during reviews, it's how we confirm whether a Conditional Access authentication context is actually being satisfied end-to-end.
When a user satisfies an authentication context, the resulting access token contains a few claims worth knowing about:
{
"aud": "api://modern42.entra-conditional-access.authsuite",
"iss": "https://login.microsoftonline.com/aaaa.../v2.0",
"iat": 1747100000,
"exp": 1747103600,
"auth_time": 1747099980,
"scp": "Approvals.Write",
"oid": "11112222-bbbb-3333-cccc-4444dddd5555",
"tid": "aaaa1111-bbbb-2222-cccc-3333dddd4444",
"acrs": ["c1"],
"xms_cc": ["cp1"],
"amr": ["pwd", "mfa", "fido"],
"ver": "2.0"
}A few of these matter and have surprising semantics:
acrs is the multi-value array of satisfied authentication context IDs. This is the claim that drives the whole feature. The API checks this against the operation's required context. If the value is there, access is granted.
acr (singular, v1.0 tokens only) is not the same as acrs. It's the legacy OpenID Connect "Authentication Context Class Reference" from ISO/IEC 29115, and it carries either 0 or 1. Different vintage, different feature, confusingly similar name. v2.0 tokens drop it. We mention it because the names trip people up.
amr describes which authentication methods the user actually used in this session, values like pwd, mfa, fido, wia. It is descriptive, not prescriptive. You can't request amr values via the claims parameter to drive step-up. If you've tried {"access_token":{"amr":{"essential":true,"value":"mfa"}}} and wondered why nothing happened, this is why. To actually require MFA, bind a Conditional Access policy that requires MFA to an authentication context, and ask for acrs matching that context.
xms_cc is the client capabilities claim. cp1 means the client has declared it understands claims challenges. Required if you're going down the WWW-Authenticate route. Irrelevant if your UI is asking for the context directly.
auth_time is when the user actually authenticated. Different from iat, which is when the token was minted. We'll come back to this in the step-up section.
For your application to receive acrs and xms_cc in tokens, both claims have to be configured as optional claims on the API's app registration manifest, separately for access tokens and ID tokens. We see this missed regularly during reviews. The policy fires, the user satisfies the prompt, Entra issues a token, but the API never sees the proof because nobody opted the resource into receiving the claim.
There's also a subtle behaviour called opportunistic evaluation that catches engineers off guard. Once the API has opted into acrs, Entra will preemptively populate the claim with every context whose underlying policies are already satisfied at sign-in. A user who signed in via Conditional Access requiring MFA on a compliant device will get acrs: ["c1"] automatically, with no claims challenge round-trip, if c1 happens to be the "MFA on compliant device" context. This is the opposite of friction. It's why customers who deploy authentication contexts often report fewer prompts than they expected, not more.
Watching the claims appear with SuiteAuth
The simplest way to see authentication contexts in action is to inspect the tokens before and after. We use SuiteAuth for this internally because it captures every request to login.microsoftonline.com and decodes the resulting token in place.
Here's what a normal sign-in token looks like. The user has authenticated with username, password, and MFA. They have everything they need for ordinary application access.

Notice what's missing. There's no acrs claim. As far as the API is concerned, this user hasn't satisfied any authentication contexts. Whatever sensitive operation needs c1 is still gated.
Now we change one thing. We tell MSAL we want a token with acrs equal to c1. SuiteAuth lets us do this by adding a claims parameter directly to the authorisation request, the exact same JSON our UI code would pass when calling acquireTokenSilent.

When we sign in, Entra runs the Conditional Access policy bound to c1. The policy requires FIDO2, so the user has to produce their security key, even though MFA was already done earlier. Once satisfied, the token comes back, and SuiteAuth decodes it next to the old one.

That's the entire pattern. Ask for the context in the claims request. Get a token containing it. Show the token to the API. The API checks that acrs contains what it needs. Done.
The complexity people associate with authentication contexts, the 401 dance, the WWW-Authenticate parsing, the cp1 capability handshake, exists to make this work without the UI knowing what to ask for upfront. If your UI knows, you can skip the complexity entirely.
Step-up authentication and the token replay problem
So far so good. Authentication contexts let you trigger MFA step-up at the operation level. The token comes back with the right acrs value. The API checks it. The payment goes through.
But here's the uncomfortable question: how long does that token live? Default Entra ID access tokens are valid for around an hour. CAE-aware sessions can extend that to 28 hours. Either way, once a token containing acrs: ["c1"] exists, it can be replayed against the API for the rest of its lifetime. The next twenty payment approvals don't trigger another FIDO2 prompt. The user satisfied the policy once, and the satisfaction is baked into the token until it expires.
For most use cases, that's fine. The point of authentication contexts is to add precision, not to demand fresh authentication on every single click. But for genuinely high-risk operations, large payments, irreversible administrative changes, exporting sensitive data, "satisfied once in the last hour" isn't the same as "satisfied right now for this action".
The naive approach we sometimes see in the wild is to check the token's iat (issued at) claim and reject it if it's older than some threshold. This is genuinely tempting because iat is right there in the token, the check is one line of code, and it feels like a freshness check.
It isn't. Three problems:
iatmeasures the token, not the authentication. A refresh token can mint new access tokens silently for hours without the user touching anything. The new access token has a freshiatand the sameacrsandamrclaims as the old one. No actual reauthentication happened. Theauth_timeclaim, when the user last authenticated, is closer to what you want, but it has its own subtleties.- Opportunistic evaluation can populate
acrswithout a fresh prompt. As covered earlier, if the user has already satisfied the underlying policy at any point in the session, theacrsclaim can appear without a new policy evaluation. The token looks satisfied; the user didn't see anything. - No binding to the operation. Even if the user did just authenticate, the token doesn't carry any cryptographic binding to the specific action being approved. A token freshly minted to approve a $50 expense and a token freshly minted to approve a $5M transfer look identical.
Doing genuine per-operation step-up, where the user demonstrably authenticated for this action, right now, needs more than authentication contexts alone. It needs max_age constraints in the authorisation request, careful use of auth_time, session-frequency Conditional Access policies set to Every time, and ideally binding the action to the authentication via signed payloads. We've watched customers reach for iat and convince themselves they have step-up. They don't.
We'll be publishing a follow-up article that demonstrates this first-hand, with a working LOB app, SuiteAuth captures of token replay, and the patterns we recommend for actually constraining high-risk operations to fresh authentications. Watch this space, or get in touch if you want to talk through your specific scenario before then.
When to use authentication contexts (and when not to)
Authentication contexts are the right tool when parts of an application need a higher bar than the application overall. Common patterns we recommend:
- PIM role activation. Bind a context to a CA policy requiring FIDO2 every time, and admins must produce a fresh phishing-resistant authentication at the moment they activate a privileged role. Token theft from a Windows-Hello-signed-in machine doesn't help an attacker. The highest-leverage configuration we recommend on most engagements.
- Sensitivity-labelled SharePoint sites. Microsoft Purview lets you attach a context to a sensitivity label. Apply the label to an HR or M&A site, and access through SharePoint will satisfy the bound CA policy before content loads.
- Defender for Cloud Apps session policies. Trigger step-up the moment a user tries to download a labelled file from inside a session, rather than blocking the whole session.
- Custom line-of-business APIs. The "the UI asks Entra for the right context" pattern from earlier. The application defines its own sensitive operations and the UI knows which ones need which contexts.
Where they're not the right answer:
- When the whole application should be gated. If access to the entire app should require MFA, target the app directly in your Conditional Access policy. Authentication contexts are for within an app, not whether the app should be reachable.
- For workload-only flows. Authentication contexts work for user sign-ins today. Pure client-credential / app-only flows aren't covered the same way, though Microsoft has previews around agent identities and Conditional Access for workload identities heading in this direction.
- As a substitute for baseline MFA. Authentication contexts are precision controls. They sit on top of a baseline policy that requires MFA broadly. If your baseline is weak, no amount of operation-level step-up will save you. We've written about why an unconditional MFA baseline matters in the context of device filter bypasses, and the same logic applies here.
A few practical things we see catch teams out during reviews:
-
Resource APIs must opt into
acrsas an optional claim on the manifest, and access tokens and ID tokens are configured independently. Missing this is the most common reason an authentication context appears to "do nothing" in testing. To verify the optional claim is configured on a given API app registration without leaving PowerShell:(Get-MgApplication -Filter "appId eq '<your-api-app-id>'").OptionalClaims.AccessToken | Where-Object Name -eq 'acrs'If that returns nothing, the API isn't receiving
acrsin its access tokens, and no amount of CA policy work will surface the satisfied context to your application. -
Don't hardcode context IDs in source. Read them via Microsoft Graph at config time. Context IDs are tenant-scoped, so
c1in one tenant has no relationship toc1in another. Essential for multi-tenant SaaS. -
If you bind a PIM role to an authentication context but the Conditional Access policy doesn't exist, is disabled, is in report-only mode, or excludes the user, PIM falls back to plain MFA without warning. The fallback only fires for missing policies, not weakened ones. Worth auditing.
-
If you do go down the API-driven WWW-Authenticate route, your clients must declare the
cp1capability or your APIs won't send challenges. The pattern silently degrades to plain 401s otherwise.
Mandatory plug
Step-up authentication, done well, is a quiet feature. Users only see prompts at the exact moments the policy demands them, and the protocol stays out of the way the rest of the time. Done badly, it's invisible to everyone, including the attacker who finds the gap, or the developer who thought iat was enough.
If you want help designing authentication contexts for your tenant, identifying which operations should be protected, or pressure-testing the policies you already have, that's the kind of work our team does day in and day out. Get in touch and we can walk you through what we'd recommend for your environment. If you're a purple team interested in watching the OAuth dance the way SuiteAuth does in the screenshots above, the closed beta is open for waitlist sign-up.




