Implement Azure AD Continuous Access in an ASP.NET Core Razor Page app using a Web API

Regular conditional access in Azure AD is evaluated at sign-in time. A user authenticates, the policy runs, a token gets issued, and that token stays valid until it expires, typically an hour later, regardless of anything that happens in between. Continuous Access Evaluation, CAE, changes that by letting Azure AD revoke a token’s validity in near real time and force a step-up authentication mid-session, without waiting for the token to naturally expire.

This is genuinely useful for a specific, narrow case: an admin API that should demand fresh MFA even if the user already has a valid session from an hour ago doing something less sensitive. This walkthrough implements exactly that, an ASP.NET Core Razor Pages UI calling a separately hosted admin API, both secured with Microsoft.Identity.Web, with the API requiring a CAE authentication context that forces MFA before it will return data.

Licensing Reality Check Before You Start

Worth clearing up early since it affects whether this is even reachable for your tenant. CAE itself is available to every tenant, including free ones. Conditional Access Authentication Context, the specific mechanism this walkthrough relies on, depends on Conditional Access as a feature, which requires Azure AD Premium P1 or above. If your tenant is on the free tier, you can read this entire article and still not be able to configure the authentication context piece without upgrading first.

Creating a Conditional Access Authentication Context

An authentication context is a named, reusable condition, three of them exist in Microsoft’s own sample, that a Conditional Access policy can require and that an application can request. Creating one needs the Policy.Read.ConditionalAccess and Policy.ReadWrite.ConditionalAccess Graph permissions, and is a one-time setup step, not something your running application needs permission to do on every request.

public async Task CreateAuthContextViaGraph(string acrKey, string acrValue)
{
    await _graphAuthContextAdmin.CreateAuthContextClassReferenceAsync(
        acrKey,
        acrValue,
        $"A new Authentication Context Class Reference created at {DateTime.UtcNow}",
        true);
}
 
public async Task<AuthenticationContextClassReference?>
    CreateAuthContextClassReferenceAsync(
        string id,
        string displayName,
        string description,
        bool IsAvailable)
{
    try
    {
        var acr = await _graphServiceClient
            .Identity
            .ConditionalAccess
            .AuthenticationContextClassReferences
            .Request()
            .AddAsync(new AuthenticationContextClassReference
            {
                Id = id,
                DisplayName = displayName,
                Description = description,
                IsAvailable = IsAvailable,
                ODataType = null
            });
 
        return acr;
    }
    catch (ServiceException e)
    {
        _logger.LogWarning("We could not add a new ACR: {exception}", e.Error.Message);
        return null;
    }
}

Once created, the context shows up under the Security blade of the Azure AD tenant in the portal, alongside its ID, which is the value your application code actually checks against.

Conditional Access Authentication Context created and visible in the Azure AD Security blade.
Conditional Access Authentication Context created and visible in the Azure AD Security blade.
Authentication context detail, showing the id value referenced by the application.
Authentication context detail, showing the id value referenced by the application.

Attaching a CAE Policy to the Context

The authentication context on its own does nothing, it needs a Conditional Access policy that actually requires something, MFA in this case, whenever that context is presented. This part is entirely tenant admin configuration in the Azure Portal, not application code.

Conditional Access policy requiring MFA, scoped to the authentication context created earlier.
Conditional Access policy requiring MFA, scoped to the authentication context created earlier.

This is worth internalizing before writing any code: the application can only request that a particular authentication context be satisfied. It has no ability to force the underlying policy to exist or to behave a certain way, that is entirely up to whatever the tenant admin has configured. Your app’s job is to check for the claim and respond correctly when it is missing, not to enforce the MFA requirement itself.

Checking the Claim on the API Side

The API needs to check that the access token carries an acrs claim matching the required context ID. If CAE is active and that claim is missing or does not match, the API must respond in the specific way the OpenID Connect claims-challenge specification defines, a 401 with a particular WWW-Authenticate header, so the calling client knows exactly what additional authentication is required rather than just seeing a generic failure.

public class CaeClaimsChallengeService
{
    private readonly IConfiguration _configuration;
 
    public CaeClaimsChallengeService(IConfiguration configuration)
    {
        _configuration = configuration;
    }
 
    public void CheckForRequiredAuthContext(string authContextId, HttpContext context)
    {
        if (!string.IsNullOrEmpty(authContextId))
        {
            string authenticationContextClassReferencesClaim = "acrs";
 
            var acrsClaim = context.User.FindAll(authenticationContextClassReferencesClaim)
                .FirstOrDefault(x => x.Value == authContextId);
 
            if (acrsClaim?.Value != authContextId)
            {
                if (IsClientCapableofClaimsChallenge(context))
                {
                    string clientId = _configuration["AzureAd:ClientId"];
                    var base64str = Convert.ToBase64String(Encoding.UTF8.GetBytes(
                        "{\"access_token\":{\"acrs\":{\"essential\":true,\"value\":\"" + authContextId + "\"}}}"));
 
                    context.Response.Headers.Append("WWW-Authenticate",
                        $"Bearer realm=\"\", authorization_uri=\"https://login.microsoftonline.com/common/oauth2/authorize\", " +
                        $"client_id=\"{clientId}\", error=\"insufficient_claims\", claims=\"{base64str}\", cc_type=\"authcontext\"");
                    context.Response.StatusCode = (int)HttpStatusCode.Unauthorized;
 
                    throw new UnauthorizedAccessException(
                        "The presented access token had insufficient claims.");
                }
                else
                {
                    throw new UnauthorizedAccessException(
                        "The caller does not meet the authentication bar for this operation.");
                }
            }
        }
    }
 
    public bool IsClientCapableofClaimsChallenge(HttpContext context)
    {
        var ccClaim = context.User.FindAll("xms_cc").FirstOrDefault(x => x.Type == "xms_cc");
        return ccClaim != null && ccClaim.Value == "cp1";
    }
}

The IsClientCapableofClaimsChallenge check is not a formality. If the calling client never advertised the cp1 client capability in the first place, it has no idea how to interpret a claims challenge response, and sending it one just breaks the call with no way for the client to recover. Checking xms_cc first is what keeps this graceful for clients that were never built to understand CAE at all, they get a plain unauthorized response instead of a challenge they cannot act on.

This service gets invoked directly inside the controller action, not as blanket middleware, since the required authentication context is specific to which endpoint is being called, an admin action is a different bar than a read-only one.

[Authorize(Policy = "ValidateAccessTokenPolicy", AuthenticationSchemes = JwtBearerDefaults.AuthenticationScheme)]
[ApiController]
[Route("[controller]")]
public class ApiForUserDataController : ControllerBase
{
    private readonly CaeClaimsChallengeService _caeClaimsChallengeService;
 
    public ApiForUserDataController(CaeClaimsChallengeService caeClaimsChallengeService)
    {
        _caeClaimsChallengeService = caeClaimsChallengeService;
    }
 
    [HttpGet]
    public IEnumerable<string> Get()
    {
        _caeClaimsChallengeService.CheckForRequiredAuthContext(AuthContextId.C1, HttpContext);
        return new List<string> { "admin API CAE protected data 1", "admin API CAE protected data 2" };
    }
}

Wiring Up the API Host

The API’s Program.cs is mostly standard Microsoft.Identity.Web setup, with the same azp and azpacr claim checks worth carrying over from the general app-to-app pattern, confirming the token was issued to the expected client and authenticated with a real credential rather than as a public client.

builder.Services.AddScoped<CaeClaimsChallengeService>();
 
builder.Services.AddDistributedMemoryCache();
builder.Services.AddMicrosoftIdentityWebApiAuthentication(builder.Configuration)
    .EnableTokenAcquisitionToCallDownstreamApi()
    .AddMicrosoftGraph(builder.Configuration.GetSection("GraphBeta"))
    .AddDistributedTokenCaches();
 
JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
 
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
    {
        validateAccessTokenPolicy.RequireClaim("azp", builder.Configuration["AzpValidClientId"]);
        validateAccessTokenPolicy.RequireClaim("azpacr", "1");
    });
});

The one configuration detail that is easy to miss entirely, since it produces no error if you forget it, is the ClientCapabilities entry in appsettings, which is what actually tells Azure AD this application understands CAE claims challenges at all.

"AzureAd": {
  "ClientId": "...",
  "ClientSecret": "...",
  "ClientCapabilities": [ "cp1" ],
  "CallbackPath": "/signin-oidc"
},
"AzpValidClientId": "7c839e15-096b-4abb-a869-df9e6b34027c",
"GraphBeta": {
  "BaseUrl": "https://graph.microsoft.com/beta",
  "Scopes": "Policy.Read.ConditionalAccess Policy.ReadWrite.ConditionalAccess"
}

Without cp1 declared here, Azure AD has no way of knowing the client can handle a claims challenge response, and CAE effectively cannot function correctly for this application no matter how carefully the rest of the code is written.

Handling the Challenge in the Razor Pages Client

On the calling side, the client requests data from the admin API through ITokenAcquisition as usual, but treats an unauthorized response as a signal to re-authenticate rather than a plain failure.

public async Task<IEnumerable<string>?> GetApiDataAsync()
{
    var client = _clientFactory.CreateClient();
    var scopes = new List<string> { _adminApiScope };
    var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(scopes);
 
    client.BaseAddress = new Uri(_adminApiBaseUrl);
    client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
 
    var response = await client.GetAsync("ApiForUserData");
    if (response.IsSuccessStatusCode)
    {
        var stream = await response.Content.ReadAsStreamAsync();
        return await JsonSerializer.DeserializeAsync<List<string>>(stream);
    }
 
    // Signals a claims challenge should be handled by the caller
    throw new WebApiMsalUiRequiredException(
        $"Unexpected status code: {response.StatusCode}.", response);
}

The Razor Page handler catches that specific exception, pulls the claims challenge out of the response headers, and redirects the user through a fresh authentication flow that satisfies the required context, MFA in this case, before retrying.

public async Task<IActionResult> OnGet()
{
    try
    {
        Data = await _userApiClientService.GetApiDataAsync();
        return Page();
    }
    catch (WebApiMsalUiRequiredException hex)
    {
        try
        {
            var claimChallenge = WwwAuthenticateParameters
                .GetClaimChallengeFromResponseHeaders(hex.Headers);
 
            _consentHandler.ChallengeUser(new string[] { "user.read" }, claimChallenge);
            return Page();
        }
        catch (Exception ex)
        {
            _consentHandler.HandleException(ex);
        }
 
        _logger.LogInformation("{hexMessage}", hex.Message);
    }
 
    return Page();
}

From the user’s point of view, they were already signed in, browsing normally, and the moment they hit an action requiring the CAE-protected context, they get redirected to re-authenticate, typically an MFA prompt, and land back on the same page once that succeeds. No manual sign-out, no broken session, just a step-up exactly at the point that needed it.

What This Cannot Do

It bears repeating plainly: none of this application code creates the security guarantee on its own. CAE only works end to end if the tenant’s Conditional Access policies are configured correctly by whoever administers Azure AD for that organization. Your application can check for the claim and handle the challenge gracefully, but it cannot force a policy into existence, and a misconfigured or missing policy on the tenant side means the whole flow silently does nothing, the API will simply never see the acrs claim requirement fail because there was never a policy requiring it in the first place.

This also is not something you sprinkle onto every endpoint by default. CAE step-up adds real friction for the end user, a fresh MFA prompt mid-session is disruptive, and it should be reserved for genuinely higher-risk actions, admin operations, sensitive data access, destructive operations, rather than applied uniformly across an entire API surface. Reserve it for the handful of actions where the extra assurance is actually worth the interruption.

Leave a Reply

Discover more from Behind the Stack

Subscribe now to keep reading and get access to the full archive.

Continue reading