Force phishing resistant authentication in an ASP.NET Core application using Azure AD

Most Azure AD conditional access setups apply phishing-resistant authentication at the tenant level, which means every user or every app in scope gets the same requirement. That works fine for a blanket policy, but it does not help when only one page of one application, say an admin console, needs a stronger authentication guarantee while the rest of the tenant continues using whatever methods are already enrolled. This article walks through a pattern that forces phishing-resistant authentication from inside the application code itself, using a conditional access authentication context and the acrs claim, rather than depending entirely on how a tenant admin has configured policies.

The approach combines three pieces that are each useful on their own: a custom authentication method definition in Microsoft Entra, a conditional access authentication context tied to a specific policy, and Continuous Access Evaluation (CAE) claims challenges inside the ASP.NET Core app. Put together, they let a developer guarantee, at the code level, that a specific page cannot be reached unless the signed-in user has actually completed a phishing-resistant method such as FIDO2.

What counts as phishing-resistant authentication

Phishing-resistant methods are ones that cannot be relayed to an attacker through a fake login page, because the cryptographic proof is bound to the origin the user is authenticating to. FIDO2 security keys, Windows Hello for Business, and certificate-based authentication fall into this category. A one-time code typed into a form, or even a push notification approved on the Authenticator app, does not qualify, because a user can be tricked into approving a push or typing a code on an attacker-controlled site. This distinction matters for the pattern below: even a user who has MFA enabled through the Authenticator app will not satisfy the requirement built in this article, and that is by design.

Step 1: Define the authentication method in Microsoft Entra

Open entra.microsoft.com and go to the authentication methods section. Azure AD already ships a predefined phishing-resistant authentication strength, but it is also possible to create a custom one if you want to restrict things further, for example to accept only your organization’s own FIDO2 keys rather than any FIDO2 device on the market.

Authentication method definitions in Microsoft Entra, including the built-in phishing-resistant strength.
Authentication method definitions in Microsoft Entra, including the built-in phishing-resistant strength.

Creating a custom authentication strength gives you a named policy you can reference later, separate from the tenant default. This is useful if you eventually want a stricter definition for admin roles than for regular users, without touching the global phishing-resistant strength that other conditional access policies might already depend on.

Configuring a custom authentication strength that only accepts specific phishing-resistant methods.
Configuring a custom authentication strength that only accepts specific phishing-resistant methods.

Step 2: Create a conditional access authentication context

An authentication context is what lets the application, rather than only the tenant-wide policy, trigger a specific conditional access requirement on demand. Without it, you would have no way to say from application code which policy needs to apply to a particular request. Create a new authentication context from the Microsoft Entra portal.

Creating a new conditional access authentication context in Microsoft Entra.
Creating a new conditional access authentication context in Microsoft Entra.

Give the context a name and an identifier. The identifier is what the application code will reference later when it needs to demand step-up authentication, so pick something you will recognize in code, such as c4 (used through this article for the phishing-resistant requirement).

Naming the authentication context and setting its identifier, c4, for the phishing-resistant requirement.
Naming the authentication context and setting its identifier, c4, for the phishing-resistant requirement.

The same authentication context can also be created through Microsoft Graph instead of the portal, which is worth doing if you want this provisioned as part of an infrastructure-as-code pipeline rather than as a manual, click-through step repeated for every tenant.

Step 3: Build the conditional access policy around the authentication context

With the authentication context created, the next step is a conditional access policy that ties the context to the phishing-resistant authentication strength. Create a new policy in the conditional access section.

Starting a new conditional access policy that will use the authentication context.
Starting a new conditional access policy that will use the authentication context.

In the cloud apps or actions section, select the authentication context you created in step 2, instead of picking specific applications directly. This is the detail that makes the whole pattern work: the policy fires whenever the app requests that specific context, not whenever a user opens a particular app registration.

Selecting the authentication context under cloud apps or actions in the conditional access policy.
Selecting the authentication context under cloud apps or actions in the conditional access policy.

In access controls, grant access only to sign-ins that satisfy the phishing-resistant authentication strength defined earlier. This is where the policy actually becomes strict, rather than just descriptive.

Access controls requiring the phishing-resistant authentication strength before access is granted.
Access controls requiring the phishing-resistant authentication strength before access is granted.

Once this policy is enabled, any request that presents the c4 authentication context must be satisfied with a phishing-resistant method, regardless of what the rest of the tenant’s default conditional access policy allows.

Step 4: Configure the Azure App registration

The ASP.NET Core application needs to validate the CAE claim itself, in the id_token, rather than relying on a downstream API to do it. To make that possible, the app registration manifest needs the xms_cc optional claim added to the id_token, which tells Azure AD the client is capable of handling claims challenges.

"optionalClaims": {
    "idToken": [
        {
            "name": "xms_cc",
            "source": null,
            "essential": false,
            "additionalProperties": []
        }
    ],
    "accessToken": [],
    "saml2Token": []
},

This block goes in the app registration manifest, not in application code. It just declares that the id_token issued to this app can carry the xms_cc claim, which Azure AD uses to recognize a client that supports client capabilities such as CAE.

On the application side, Microsoft.Identity.Web is the library doing the heavy lifting for Azure AD authentication, and it needs to know about this capability through configuration. Add the ClientCapabilities setting with the value cp1 under the AzureAd section in appsettings.json.

"AzureAd": {
    "Instance": "https://login.microsoftonline.com/",
    "Domain": "damienbodsharepoint.onmicrosoft.com",
    "TenantId": "5698af84-5720-4ff0-bdc3-9d9195314244",
    "ClientId": "daffd2e8-3718-4ac4-b971-c7f1bb570375",
    "CallbackPath": "/signin-oidc",
    "ClientCapabilities": [ "cp1" ]
    //"ClientSecret":  "--in-user-secrets-or-key-vault--"
},

The cp1 value is a standard code Azure AD uses to identify clients capable of handling CAE claims challenges. Do not put a client secret directly in appsettings.json in a real project, keep it in user secrets during development and in Key Vault in production, the commented-out line above is just marking where it would go.

A few other manifest settings are worth checking against the Microsoft.Identity.Web wiki on GitHub while you are in there, since some of them (implicit flow, reply URLs, sign-in audience) are easy to get wrong when an app registration has been copied from an older template.

"oauth2AllowIdTokenImplicitFlow": true,
"optionalClaims": {
    "idToken": [
        {
            "name": "xms_cc",
            "source": null,
            "essential": false,
            "additionalProperties": []
        }
    ],
    "accessToken": [],
    "saml2Token": []
},
"replyUrlsWithType": [
    {
        "url": "https://localhost:44414/signin-oidc",
        "type": "Web"
    }
],
"signInAudience": "AzureADMyOrg",

This is the same manifest reviewed end to end rather than a new setting, useful as a sanity check that the optional claim, reply URL, and audience are all consistent with a single-tenant web app.

Step 5: Enforce the claim inside the ASP.NET Core application

With the portal and app registration configured, the last piece is application code that checks whether the signed-in user’s id_token already carries the acrs claim with the value c4. If it does not, the app needs to send the user back through Azure AD with a claims challenge asking specifically for that authentication context, which is what triggers the conditional access policy from step 3.

using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using System;
using System.Linq;
 
namespace RazorCaePhishingResistant;
 
public class CaeClaimsChallengeService
{
    private readonly IConfiguration _configuration;
 
    public CaeClaimsChallengeService(IConfiguration configuration)
    {
        _configuration = configuration;
    }
 
    public string? CheckForRequiredAuthContextIdToken(string authContextId, HttpContext context)
    {
        if (!string.IsNullOrEmpty(authContextId))
        {
            string authenticationContextClassReferencesClaim = "acrs";
 
            if (context == null || context.User == null || context.User.Claims == null || !context.User.Claims.Any())
            {
                throw new ArgumentNullException(nameof(context), "No Usercontext is available to pick claims from");
            }
 
            var acrsClaim = context.User.FindAll(authenticationContextClassReferencesClaim).FirstOrDefault(x => x.Value == authContextId);
 
            if (acrsClaim?.Value != authContextId)
            {
                string clientId = _configuration.GetSection("AzureAd").GetSection("ClientId").Value;
                var cae = "{\"id_token\":{\"acrs\":{\"essential\":true,\"value\":\"" + authContextId + "\"}}}";
 
                return cae;
            }
        }
 
        return null;
    }
}

CheckForRequiredAuthContextIdToken looks through the current user’s claims for an acrs claim matching the authentication context id passed in, c4 in this case. If it finds a match, it returns null, meaning the user already satisfied the policy and no further action is needed. If it does not find one, it builds and returns a claims challenge string in the shape Azure AD expects, which the caller then sends back to Azure AD as part of a new sign-in request. A common mistake here is checking the claim against a raw string on the JWT rather than through context.User.FindAll, which breaks the moment the claims mapping changes even slightly.

The Razor Page for the admin area calls this service on every GET request, before returning any data. If the claims challenge is required, the page issues a Challenge result carrying the claims payload instead of rendering the page.

using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using System.Collections.Generic;
 
namespace RazorCaePhishingResistant.Pages;
 
public class AdminModel : PageModel
{
    private readonly CaeClaimsChallengeService _caeClaimsChallengeService;
 
    public AdminModel(CaeClaimsChallengeService caeClaimsChallengeService)
    {
        _caeClaimsChallengeService = caeClaimsChallengeService;
    }
 
    [BindProperty]
    public IEnumerable<string>? Data { get; private set; }
 
    public IActionResult OnGet()
    {
        var claimsChallenge = _caeClaimsChallengeService
            .CheckForRequiredAuthContextIdToken(AuthContextId.C4, HttpContext);
 
        if (claimsChallenge != null)
        {
            var properties = new AuthenticationProperties { RedirectUri = "/admin" };
            properties.Items["claims"] = claimsChallenge;
            return Challenge(properties);
        }
 
        Data = new List<string>()
        {
            "Admin data 1",
            "Admin data 2"
        };
 
        return Page();
    }
}

When Challenge runs with the claims property set, ASP.NET Core’s OpenID Connect handler restarts the sign-in flow and passes the claims request through to Azure AD, which then applies the conditional access policy tied to the c4 context. The user is redirected back to Azure AD, prompted to complete a phishing-resistant method, and only then redirected back to /admin. If they cannot complete a phishing-resistant method, or the tenant has none configured for their account, they will be blocked from reaching this page even though they may already be signed in everywhere else in the app with ordinary MFA.

Production considerations and trade-offs

This pattern protects one thing precisely: the id_token issued to this specific web application. If the same admin functionality is also exposed through a separate downstream Web API, that API needs its own validation of the acrs claim on its incoming access token, because the claim on the web app’s id_token does not automatically propagate. Skipping this on the API side is a common gap, since it is easy to assume the front end already handled it.

Compare this to just applying the phishing-resistant conditional access policy against the whole app registration or the whole tenant. A tenant-wide policy is simpler to reason about and does not need any code, but it is all-or-nothing, and it can lock out legitimate users who have not yet enrolled a FIDO2 key or Windows Hello for Business, even for parts of the app that do not need that level of assurance. The per-context approach shown here costs more in setup and code, but it lets you apply a strict requirement only where the risk actually justifies it, such as an admin console, a billing page, or an endpoint that can change security settings.

Worth testing explicitly before relying on this in production: what happens when a user has no phishing-resistant method enrolled at all. Azure AD will not silently downgrade the requirement, the user simply cannot complete the challenge and stays blocked from the page, so any rollout of this pattern needs a parallel FIDO2 or Windows Hello for Business enrollment push, or the admin page becomes unreachable for people who need it.

Notes and limitations

This setup works reliably once the authentication context, conditional access policy, and app registration are all aligned, and it genuinely forces phishing-resistant authentication from inside the application rather than hoping a tenant-wide policy covers the case. One limitation worth repeating: MFA completed through the Microsoft Authenticator app, whether by push approval or by code, does not satisfy this requirement, only methods that resist credential relay and phishing, such as FIDO2 security keys, Windows Hello for Business, and certificate-based authentication, will pass the c4 authentication context.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading