The step-up authentication series: Part 1: Step-up MFA with authentication contexts (you are here) · Part 2: Defeating token replay with end-to-end step-up · Part 3: Limiting token lifetimes in Entra ID
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 risks associated with invoice payments path, so they reach for Conditional Access Sign-In Frequency, set it to one hour, scope it to All Users on the finance app.
Cyber may be happy but 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 real step-up.
What the team needed was an authentication control bound to the act of approving a payment, not to the clock. That is where Conditional Access authentication contexts step in to help us. Sign in once with normal MFA, do your low-risk work without prompts, and the moment a user attempts to perform a "high risk" action 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 low-assurance, low-friction flow. The handful of high-risk actions get challenged at the moment that actually matters.
This is Part 1 of a three-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 security model shines and where it fails to deliver. Part 2 tackles the deeper problem, token replay against high-risk operations, and builds the end-to-end pattern that binds each authentication to one specific operation, exactly once. Part 3 covers the supporting cast: every lever Entra ID gives you to limit how long a token, and therefore a stolen token, stays valid.
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.
Why Write this Series?
Authentication Contexts are documented in great detail. However, they are documented from the perspective of software developers building web applications. Rightfully so, but it leads to convoluted way of communicating the fundamental authentication mechanism that us System Admins have to protect our organisations. In addition, it doesn't show how we can implement Single Use step up authentication.
That's why this series isn't going to talk in great detail about how the API and UI should communicate requirements to each other. We will focus more on the relationship between the application system and the identity provider.
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.
It is important to understand that an authentication context is a label which means nothing until you apply a conditional access policy AND during user authentication your application requests it (and requires it for authorization).
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://modern42-finance/.default
&claims={"access_token":{"acrs":{"essential":true,"value":"c1"}}}
&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. Once complete, 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 the Identity Provider (Entra ID).
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 interface. Teams reading those samples often assume the whole 401 dance is mandatory. It isn't. For simple line-of-business apps where the UI knows what each action needs, you can ask Entra for the right context upfront and skip the API to UI 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. The rest is just the "nice to haves" for building robust enterprise systems.
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.
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.
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.
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.
When to use authentication contexts (and when not to)
Authentication contexts are the right tool when parts of an application need a higher assurance 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. Maybe accessing payroll information requires phishing resistant MFA or Compliance Device.
Where they're not the right answer:
- Critical Risk Activities. If an operation or action must have non-repudiation and must have high level of authorization confidence.
- 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.
Conclusion
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.
This article was about precision, getting the right prompt in front of the right user at the right moment. But precision is not the same as freshness. A satisfied acrs claim tells you a user stepped up at some point in their session, not that they stepped up for this action, right now, and as we saw with the token replay problem, that gap is exactly where a stolen token does its damage. Part 2 of this series picks up there. We capture a real token replay against a high-risk operation with SuiteAuth, then build the end-to-end pattern that binds each authentication to one specific action, exactly once.
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.




