If you are building software that gets deployed into other people’s Azure AD tenants, you cannot assume every customer tenant has MFA enforced correctly, or at all. Plenty of tenants do not. Waiting on tenant admins to configure conditional access properly is not a plan, it is a hope, and for an ISV shipping a product across many tenants that hope is not good enough.
Continuous Access Evaluation authentication contexts let the application itself demand step-up MFA at sign-in, regardless of what the customer tenant’s own conditional access policies say. This walkthrough covers forcing that requirement from a Blazor application specifically, building on top of a CA authentication context and policy already created via Microsoft Graph.
The Authentication Context Has to Exist First
Before any application code can force MFA, a conditional access authentication context needs to exist in the tenant, created through Microsoft Graph, with a policy attached requiring MFA whenever that context is presented. This is tenant-side setup, not something the app can create for itself at runtime, it is a prerequisite the ISV or tenant admin sets up once.

Once that context exists, the application’s job is simply to ask for it by value whenever it wants to force the step-up, using the acrs claim inside the id_token.
Forcing the Context From the Login Action
The most direct approach is setting the claims challenge on the login action in the account controller. Passing a specific claims JSON string as part of the AuthenticationProperties tells Azure AD to require that authentication context before completing sign-in.
[HttpGet("Login")]
public ActionResult Login(string? returnUrl,
string? claimsChallenge)
{
var redirectUri = !string.IsNullOrEmpty(returnUrl) ? returnUrl : "/";
var properties = new AuthenticationProperties { RedirectUri = redirectUri };
if (claimsChallenge != null)
{
string jsonString = claimsChallenge.Replace("\\", "")
.Trim(new char[1] { '"' });
properties.Items["claims"] = jsonString;
}
else
{
// Force MFA using CAE for all sign in requests.
properties.Items["claims"]
= "{\"id_token\":{\"acrs\":{\"essential\":true,\"value\":\"c1\"}}}";
}
return Challenge(properties);
}
There are two ways this method ends up setting the claims parameter. If the caller already has a specific claims challenge, from a downstream API returning a 403 with a WWW-Authenticate claims challenge, for instance, that exact challenge is passed through unmodified. Otherwise, the else branch unconditionally forces the c1 authentication context on every sign-in, which is the piece that actually guarantees MFA regardless of what the login request originally asked for.
“c1” here is not a magic string, it is whatever identifier you assigned the authentication context when you created it through Graph. Whatever value you use must match exactly between the context creation step and this claims JSON, a mismatch here fails silently rather than throwing an obvious error, since Azure AD simply will not recognize an acrs value it does not have a context for.
Pairing this with an authorization policy that requires the acrs claim on incoming requests closes the loop, since forcing the challenge at login is only half the story, the application still needs to check that the claim actually landed on the resulting principal.
services.AddMicrosoftIdentityWebAppAuthentication(configuration)
.EnableTokenAcquisitionToCallDownstreamApi(initialScopes)
.AddMicrosoftGraph("https://graph.microsoft.com/v1.0", scopes)
.AddInMemoryTokenCaches();
services.AddAuthorization(options =>
{
options.AddPolicy("ca-mfa", policy =>
{
policy.RequireClaim("acrs", AuthContextId.C1);
});
});
RequireClaim on acrs is what actually enforces this at the authorization layer, without it, forcing the claims challenge at login only affects the sign-in prompt, it does nothing to stop a request from a token that somehow lacks the acrs value from reaching a protected endpoint.
Forcing It Everywhere, Not Just at the Login Action
The account controller approach only forces the auth context on requests that actually go through that specific login action. If sign-in can be triggered from more than one place in the app, and in practice it usually can, that is a gap. The safer fix is a middleware hook on OnRedirectToIdentityProvider that appends the claims parameter to every outgoing OIDC authorize request, not just the ones routed through your custom login action.
services.AddRazorPages().AddMvcOptions(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.RequireClaim("acrs", AuthContextId.C1)
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
}).AddMicrosoftIdentityUI();
services.Configure<MicrosoftIdentityOptions>(OpenIdConnectDefaults.AuthenticationScheme, options =>
{
options.Events.OnRedirectToIdentityProvider = context =>
{
if (!context.ProtocolMessage.Parameters.ContainsKey("claims"))
{
context.ProtocolMessage.SetParameter(
"claims",
"{\"id_token\":{\"acrs\":{\"essential\":true,\"value\":\"c1\"}}}");
}
return Task.FromResult(0);
};
});
The ContainsKey check before setting the parameter matters, it avoids clobbering a claims parameter that was already set for a genuine step-up challenge elsewhere in the request pipeline, only adding the default MFA requirement when nothing more specific has already been requested. With this in place you generally do not need the account controller override from earlier at all, this single middleware hook covers every sign-in path uniformly.
Which Approach to Actually Use
If your app has exactly one entry point for sign-in and you are comfortable owning that account controller, the login action override is simpler to reason about and keeps the logic in one obvious place. For anything with multiple sign-in triggers, or where you cannot guarantee every path funnels through a single controller action, the OnRedirectToIdentityProvider middleware is the safer default since it cannot be bypassed by a code path you forgot about.
One thing worth flagging for anyone extending this to Blazor Server specifically: forcing a claims challenge mid-session on an already connected SignalR circuit is a different problem than forcing it at initial sign-in, since the circuit needs to detect the invalidated principal and force a reconnect through the challenge, which this walkthrough does not cover and needs its own handling.
Leave a Reply