A lot of real world APIs are not consumed by a single type of client. You might have a web application calling your API on behalf of a signed in user, and at the same time a background job or another service calling the same API using its own application identity. Both clients get access tokens from Azure AD, but the tokens come from different app registrations, carry different claims, and honestly should never be allowed to authenticate against each other’s endpoints. This article walks through how to set up an ASP.NET Core API that accepts access tokens issued by two separate Azure AD app registrations, while making sure a token meant for one endpoint cannot be replayed against the other.
The core idea is simple once you see it: ASP.NET Core authentication schemes and authorization policies were built exactly for this kind of separation. Each app registration gets its own scheme, its own policy, and its own claim checks. A controller then explicitly opts into one scheme and one policy, so there is no ambiguity about which token is acceptable where.
The setup
Azure AD acts as the identity provider here and issues the access tokens. Two Azure AD app registrations are involved. The first one is a multi tenant application registration meant for application clients, that is, services calling the API with their own identity rather than on behalf of a user. Any tenant with the correct application role claim could theoretically call this endpoint, so if you go this route you should think carefully about whether you also need to validate which tenant actually acquired the token, especially if a client secret ends up shared somewhere it should not be. The second app registration is single tenant and delegated, meaning a real user signs in and the token is issued on behalf of that user. It is scoped to a second, separate API endpoint.

A test client is used to exercise both flows. It is a server rendered confidential client application that can acquire both kinds of tokens, application and delegated, and send them to the correct endpoint. It can also deliberately send a token to the wrong endpoint, which is exactly the scenario this whole setup is meant to block.
Wiring up the API with two schemes
The Microsoft.Identity.Web package makes this fairly painless through the AddMicrosoftIdentityWebApi extension method. Instead of calling it once, you call it twice, each time against a different configuration section and a different scheme name. Each scheme becomes an independent authentication pipeline with its own validation rules.
services.AddAuthentication(Consts.AAD_MULTI_SCHEME)
.AddMicrosoftIdentityWebApi(Configuration,
"AzureADMultiApi",
Consts.AAD_MULTI_SCHEME);
services.AddAuthentication(Consts.AAD_SINGLE_SCHEME)
.AddMicrosoftIdentityWebApi(Configuration,
"AzureADSingleApi",
Consts.AAD_SINGLE_SCHEME);
Consts.AAD_MULTI_SCHEME and Consts.AAD_SINGLE_SCHEME are just string constants, but naming them explicitly instead of relying on the default scheme name is what keeps the two pipelines from ever getting mixed up later in the code. Each call reads its own section from configuration, which is where the tenant, client id and instance for that specific app registration live.
"AzureADMultiApi": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "damienbodhotmail.onmicrosoft.com",
"TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
"ClientId": "967925d5-87ea-46e6-b0eb-1223c001fd77"
},
"AzureADSingleApi": {
"Instance": "https://login.microsoftonline.com/",
"Domain": "damienbodhotmail.onmicrosoft.com",
"TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
"ClientId": "b2a09168-54e2-4bc4-af92-a710a64ef1fa"
},
Two separate ClientId values map to two separate app registrations in the Azure portal. If you are setting this up yourself, remember these are placeholder GUIDs from the original author’s tenant and you will need to swap in your own values.
Locking down claims with authorization policies
Having two schemes is only half the story. Anyone could still get a validly signed token and hit either scheme’s pipeline unless you also check the token’s own claims. That is what the authorization policies below are doing. They look at azp, the authorized party claim that identifies which client application actually requested the token, and azpacr, which tells you whether the client authenticated with a secret or a certificate.
services.AddAuthorization(policies =>
{
policies.AddPolicy(Consts.MUTLI_AAD_POLICY, p =>
{
// application access token
// "roles": [
// "application-api-role"
// ],
// "azp": "967925d5-87ea-46e6-b0eb-1223c001fd77",
p.RequireClaim("azp", "967925d5-87ea-46e6-b0eb-1223c001fd77");
// client secret = 1, 2 if certificate is used
p.RequireClaim("azpacr", "1");
});
policies.AddPolicy(Consts.SINGLE_AAD_POLICY, p =>
{
// delegated access token => "scp": "access_as_user",
// "azp": "46d2f651-813a-4b5c-8a43-63abcb4f692c",
p.RequireClaim("azp", "46d2f651-813a-4b5c-8a43-63abcb4f692c");
// client secret = 1, 2 if certificate is used
p.RequireClaim("azpacr", "1");
});
});
Requiring azp pins the policy to one specific client application id, so even if somebody got hold of a validly issued token from a completely different client in the same tenant, the claim check would reject it. Requiring azpacr equal to 1 confirms the client authenticated with a secret rather than a certificate; if you move to certificate based authentication for a client later, this value changes to 2 and you would need to update the policy accordingly. This is a detail that is easy to miss when copying code from an older sample.
Enforcing authentication globally and per controller
A global authorization filter on AddControllers makes sure every controller requires an authenticated user through one of the two schemes by default, so nobody accidentally exposes an endpoint without authentication.
services.AddControllers(options =>
{
var policy = new AuthorizationPolicyBuilder()
.RequireAuthenticatedUser()
.AddAuthenticationSchemes(
Consts.AAD_MULTI_SCHEME,
Consts.AAD_SINGLE_SCHEME)
.Build();
options.Filters.Add(new AuthorizeFilter(policy));
});
The middleware pipeline itself stays the standard ASP.NET Core setup, nothing exotic here.
app.UseRouting();
app.UseAuthentication();
app.UseAuthorization();
app.UseEndpoints(endpoints =>
{
endpoints.MapControllers();
});
The interesting part happens at the controller level, where each controller explicitly declares which scheme and which policy apply to it using the standard Authorize attribute.
[Authorize(AuthenticationSchemes = Consts.AAD_MULTI_SCHEME,
Policy = Consts.MUTLI_AAD_POLICY)]
[Route("api/[controller]")]
public class MultiController : Controller
{
[HttpGet]
public IEnumerable<string> Get()
{
return new string[] {
"data 1 from the multi api",
"data 2 from multi api" };
}
}
This is the attribute that ties everything together. If a request arrives at the Multi controller carrying a token issued through the single tenant delegated flow, it fails at authentication because that token was validated under a different scheme and does not carry the azp value the policy expects. There is no separate manual check needed in the controller body, the attribute is doing all the work, which keeps the controller code itself clean and focused on the actual business logic.
Testing with a confidential client application
To validate the setup end to end, the author built a Razor Pages test application that authenticates using the OpenID Connect confidential client authorization code flow, then acquires both kinds of tokens through separate service classes. The delegated token comes from a service that wraps ITokenAcquisition, the standard Microsoft.Identity.Web helper for getting tokens on behalf of a signed in user.
using Microsoft.Identity.Web;
using System.Net.Http.Headers;
namespace RazorAzureAD;
public class SingleTenantApiService
{
private readonly IHttpClientFactory _clientFactory;
private readonly ITokenAcquisition _tokenAcquisition;
private readonly IConfiguration _configuration;
public SingleTenantApiService(IHttpClientFactory clientFactory,
ITokenAcquisition tokenAcquisition,
IConfiguration configuration)
{
_clientFactory = clientFactory;
_tokenAcquisition = tokenAcquisition;
_configuration = configuration;
}
public async Task<List<string>> GetApiDataAsync(bool testIncorrectMultiEndpoint = false)
{
var client = _clientFactory.CreateClient();
var scope = _configuration["AzureADSingleApi:ScopeForAccessToken"];
var accessToken = await _tokenAcquisition.GetAccessTokenForUserAsync(new[] { scope });
client.BaseAddress = new Uri(_configuration["AzureADSingleApi:ApiBaseAddress"]);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
HttpResponseMessage response;
if (testIncorrectMultiEndpoint)
{
response = await client.GetAsync("api/Multi"); // must fail
}
else
{
response = await client.GetAsync("api/Single");
}
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
var data = System.Text.Json.JsonSerializer.Deserialize<List<string>>(responseContent);
if(data != null)
return data;
}
throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}
}
Notice the testIncorrectMultiEndpoint flag. It deliberately points this service, which only holds a single tenant delegated token, at the Multi endpoint. That call should come back as a 403, proving the audience separation actually works instead of just assuming it does from reading the config.
The second service handles the application client flow using MSAL directly, since this is a pure client credentials grant with a secret, no user and no OpenID Connect sign in involved.
using Microsoft.Identity.Client;
using System.Net.Http.Headers;
namespace RazorAzureAD;
public class MultiTenantApplicationApiService
{
private readonly IHttpClientFactory _clientFactory;
private readonly IConfiguration _configuration;
public MultiTenantApplicationApiService(IHttpClientFactory clientFactory,
IConfiguration configuration)
{
_clientFactory = clientFactory;
_configuration = configuration;
}
public async Task<List<string>> GetApiDataAsync(bool testIncorrectMultiEndpoint = false)
{
// 1. Client client credentials client
var app = ConfidentialClientApplicationBuilder
.Create(_configuration["AzureADMultiApi:ClientId"])
.WithClientSecret(_configuration["AzureADMultiApi:ClientSecret"])
.WithAuthority(_configuration["AzureADMultiApi:Authority"])
.Build();
var scopes = new[] { _configuration["AzureADMultiApi:Scope"] }; // default scope
// 2. Get access token
var authResult = await app.AcquireTokenForClient(scopes)
.ExecuteAsync();
// 3. Use access token to access token
var client = _clientFactory.CreateClient();
client.BaseAddress = new Uri(_configuration["AzureADMultiApi:ApiBaseAddress"]);
client.DefaultRequestHeaders.Authorization
= new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
client.DefaultRequestHeaders.Accept
.Add(new MediaTypeWithQualityHeaderValue("application/json"));
HttpResponseMessage response;
if (testIncorrectMultiEndpoint)
{
response = await client.GetAsync("api/Single"); // must fail
}
else
{
response = await client.GetAsync("api/Multi");
}
if (response.IsSuccessStatusCode)
{
var responseContent = await response.Content.ReadAsStringAsync();
var data = System.Text.Json.JsonSerializer.Deserialize<List<string>>(responseContent);
if (data != null)
return data;
}
throw new ApplicationException($"Status code: {response.StatusCode}, Error: {response.ReasonPhrase}");
}
}
Same pattern here, testIncorrectMultiEndpoint sends the application token to the Single endpoint instead, which should fail since that endpoint’s policy expects the delegated client’s azp value, not the application client’s. When you run the sample end to end, you get a simple pass or fail signal for every combination of token and endpoint, which is a fairly convincing way to prove the isolation actually holds.

Where this matters and where it does not
This pattern earns its complexity when a single API surface genuinely needs to serve more than one class of client with different trust levels, for example a public facing multi tenant integration alongside an internal delegated flow for your own front end. If your API only ever has one kind of caller, adding multiple schemes is unnecessary overhead and a single AddMicrosoftIdentityWebApi call is all you need.
One thing worth calling out for production use: the azp and azpacr claim checks here are hardcoded to specific GUID and string values in the sample. In a real project you would pull these from configuration rather than literals, and you would almost certainly want an automated test suite asserting that a token minted for endpoint A gets rejected by endpoint B, exactly the scenario the content opportunity for this article points at. Without that regression test, a well meaning refactor of the policy setup later on could silently reopen the audience confusion this whole design exists to prevent.
If you are working with multi tenant application registrations specifically, also keep in mind the author’s own caveat: validating azp alone does not tell you which tenant acquired the token. Depending on your threat model, you may need an additional claim check on the tenant id if you cannot fully trust every tenant with access to the shared secret.
Leave a Reply