Service-to-service calls where no human is involved come up constantly once you have more than one internal API in Azure. A background job needs to call an API, one microservice needs data from another, and there is no user sitting at a browser to sign in. This is exactly what the OAuth2 client credentials flow is for, and Azure AD has its own particular way of wiring it up using App Roles that is worth understanding properly before you reach for it.
This walkthrough sets up two separate ASP.NET Core applications, an API and a client, authenticating purely app-to-app using Azure AD application permissions and Microsoft.Identity.Web.
Delegated Tokens vs Application Tokens
Before touching any code, it is worth being clear on which kind of token you actually need, because Azure AD treats these two cases quite differently and picking the wrong one early is expensive to unwind later. A delegated user access token requires an actual user to sign in somewhere, and the resulting token can be scoped down per user. An application token, acquired through client credentials, has no user attached at all, the client application itself is the identity, and the permissions apply uniformly to every call that application makes.
As a general rule, prefer delegated user tokens whenever a real user is in the loop, since you can constrain permissions per user rather than granting the same blanket access to everything the client touches. Application tokens exist for the case where there genuinely is no user, a background service, a daemon, a scheduled job, and that is the only case they should be used for.
Azure AD also splits scopes and roles along this same line. Delegated access uses scope permissions. Application access uses App Roles, which only exist in an Azure AD app registration, not an Azure AD B2C one, even if the rest of your solution runs on B2C. This is a detail specific to how Azure AD models permissions, not a general OAuth2 requirement, and it catches people who assume the same permission model applies everywhere across Azure identity products.
Setting Up the App Registration
The part people underestimate is how much of this lives in Azure AD configuration rather than code. The API’s app registration needs an Application ID URI defined first, since App Roles and scopes both hang off that identifier.

With that URI in place, define an App Role of type Application, not Delegated, since the role needs to be assignable to a client application rather than a signed-in user.


The client application then needs this App Role added as an API permission against the API’s app registration, followed by admin consent. Without that consent step, the client can request a token all day and it will either fail outright or come back without the role claim, and no amount of debugging the client code will fix it, since the problem is entirely on the Azure AD configuration side.

Validating the Application Token in the API
On the API side, Microsoft.Identity.Web handles the token validation plumbing through AddMicrosoftIdentityWebApiAuthentication, but the actual authorization decision should live in a proper ASP.NET Core authorization policy rather than scattered claim checks inside controller actions.
services.AddSingleton<IAuthorizationHandler, HasServiceApiRoleHandler>();
services.AddMicrosoftIdentityWebApiAuthentication(Configuration);
services.AddControllers();
services.AddAuthorization(options =>
{
options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
{
validateAccessTokenPolicy.Requirements.Add(new HasServiceApiRoleRequirement());
// Validate the id of the application the token was issued to
validateAccessTokenPolicy.RequireClaim("azp", "2b50a014-f353-4c10-aace-024f19a55569");
// Only accept tokens where the client authenticated using a
// private key JWT / client secret, not an unauthenticated public client
// azpacr: 0 = public client, 1 = secret, 2 = certificate
validateAccessTokenPolicy.RequireClaim("azpacr", "1");
});
});
The azp and azpacr claim checks are doing more work than they look like at first glance. Checking the role alone tells you the token has the right permission, but not that it was issued to the specific client application you intended to trust, the azp claim closes that gap. The azpacr check goes a step further and confirms how the client actually authenticated itself to get the token in the first place, rejecting anything that was not a proper confidential client authentication. Skipping either check means any application that somehow obtains a role-bearing token, not just your intended client, could call the API successfully.
Requesting the Application Token from the Client
On the calling side, Microsoft.Identity.Web’s ITokenAcquisition interface handles the client credentials exchange through GetAccessTokenForAppAsync, using the /.default scope convention against the API’s Application ID URI.
public class ServiceApiClientService
{
private readonly IHttpClientFactory _clientFactory;
private readonly ITokenAcquisition _tokenAcquisition;
public ServiceApiClientService(
ITokenAcquisition tokenAcquisition,
IHttpClientFactory clientFactory)
{
_clientFactory = clientFactory;
_tokenAcquisition = tokenAcquisition;
}
public async Task<IEnumerable<string>?> GetApiDataAsync()
{
var client = _clientFactory.CreateClient();
// client credentials flow, requesting the access_as_application role
var scope = "api://b178f3a5-7588-492a-924f-72d7887b7e48/.default";
var accessToken = await _tokenAcquisition.GetAccessTokenForAppAsync(scope);
client.BaseAddress = new Uri("https://localhost:44324");
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", accessToken);
client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));
var response = await client.GetAsync("ApiForServiceData");
if (response.IsSuccessStatusCode)
{
var stream = await response.Content.ReadAsStreamAsync();
return await JsonSerializer.DeserializeAsync<List<string>>(stream);
}
throw new ApplicationException("oh no...");
}
}
The /.default suffix is specific to Azure AD’s v2 endpoint and means request whatever application permissions have already been granted and consented to for this client, rather than naming individual scopes the way a delegated flow would. If admin consent has not actually been granted for the App Role, this call will still return a token, it simply will not carry the role claim the API is checking for, and the request will fail authorization rather than authentication. That distinction is worth remembering when debugging, a 401 here often means missing consent, not a broken client.
Keeping the Client Secret Safe
None of this matters if the credential the client uses to authenticate itself is handled carelessly. The client credentials flow lives or dies on that secret, or certificate, staying out of source control and out of any client you do not fully control. If you own the client application and it runs in Azure, put the secret or certificate in Key Vault and access it through a managed identity, so the actual credential value never appears in app settings, environment variables, or a deployment script anyone can read.
This pattern is only appropriate for genuinely trusted, backend-to-backend calls. Never ship a client secret or certificate into anything that runs on a device you do not control, a mobile app, a SPA, a desktop client, since there is no way to keep it confidential there. If your calling application is one of those, you need a different pattern entirely, not a weaker version of this one.
Where to Go From Here
A client secret is the simplest credential to wire up, but a certificate-based client assertion is worth considering for anything beyond a quick internal service, since certificates are harder to accidentally leak through logs or configuration dumps and support longer rotation windows without becoming a standing secret sitting in a vault indefinitely. If your organization is also evaluating mutual TLS for service-to-service calls, it is worth comparing the two properly, mTLS authenticates at the transport layer independent of any token, while client credentials with a certificate assertion authenticates at the application layer using a signed JWT. They solve overlapping but not identical problems, and picking one over the other should depend on whether you need transport-level identity for the whole connection or just for a specific token request.
Leave a Reply