Implementing OAuth2 Client credentials flow APP to APP security using Azure AD non interactive

In an earlier piece I covered app-to-app authentication in Azure AD using Microsoft.Identity.Web, where the client credentials flow ran inside another ASP.NET Core application. That covers the common case, but not every client is a web app. A lot of real client credentials use cases are daemons, scheduled jobs, background workers, console tools, things with no ASP.NET Core host to hang Microsoft.Identity.Web off at all.

This is the companion scenario: acquiring an application token directly with Microsoft.Identity.Client, MSAL’s lower-level library, from a plain console application, and using it to call the same API secured with an Azure AD App Role. The App Role and app registration setup is identical to what I covered previously, so this piece focuses purely on what changes when the client itself is a daemon rather than a hosted web app.

The API Side Barely Changes

The API’s authorization policy is exactly the same shape as before, checking the role claim, the azp claim for the specific client ID, and azpacr to confirm the client authenticated with a secret or certificate rather than as a public client. The only difference worth calling out is two extra lines that make debugging token issues far less painful during development.

JwtSecurityTokenHandler.DefaultInboundClaimTypeMap.Clear();
IdentityModelEventSource.ShowPII = true;
JwtSecurityTokenHandler.DefaultMapInboundClaims = false;
 
services.AddSingleton<IAuthorizationHandler, HasServiceApiRoleHandler>();
services.AddMicrosoftIdentityWebApiAuthentication(Configuration);
services.AddControllers();
 
services.AddAuthorization(options =>
{
    options.AddPolicy("ValidateAccessTokenPolicy", validateAccessTokenPolicy =>
    {
        validateAccessTokenPolicy.Requirements.Add(new HasServiceApiRoleRequirement());
        validateAccessTokenPolicy.RequireClaim("azp", "b178f3a5-7588-492a-924f-72d7887b7e48");
        validateAccessTokenPolicy.RequireClaim("azpacr", "1");
    });
});

DefaultMapInboundClaims set to false stops the JWT handler from silently remapping standard claim names to long legacy XML namespace URIs, which is convenient behaviour for older WS-Federation style apps but actively gets in the way when you are inspecting a modern v2 Azure AD token and wondering why RequireClaim can’t find a claim you can plainly see in the payload. ShowPII true is strictly a development setting, since it exposes personal token content in exception details, and it should never be left on in a production deployment.

Requesting a Token with Raw MSAL

This is where the daemon scenario actually differs. Instead of Microsoft.Identity.Web wiring up token acquisition behind the scenes as part of an ASP.NET Core pipeline, a console app builds a ConfidentialClientApplication directly and calls AcquireTokenForClient itself.

using System.Net.Http.Headers;
using Microsoft.Extensions.Configuration;
using Microsoft.Identity.Client;
 
var builder = new ConfigurationBuilder()
    .SetBasePath(Directory.GetCurrentDirectory())
    .AddUserSecrets("78cf2604-554c-4a6e-8846-3505f2c0697d")
    .AddJsonFile("appsettings.json");
 
var configuration = builder.Build();
 
// 1. Build the confidential client
var app = ConfidentialClientApplicationBuilder.Create(configuration["AzureADServiceApi:ClientId"])
    .WithClientSecret(configuration["AzureADServiceApi:ClientSecret"])
    .WithAuthority(configuration["AzureADServiceApi:Authority"])
    .Build();
 
var scopes = new[] { configuration["AzureADServiceApi:Scope"] };
 
// 2. Get the application token
var authResult = await app.AcquireTokenForClient(scopes).ExecuteAsync();
 
if (authResult == null)
{
    Console.WriteLine("no auth result... ");
}
else
{
    // 3. Call the API with the token
    var client = new HttpClient
    {
        BaseAddress = new Uri(configuration["AzureADServiceApi:ApiBaseAddress"])
    };
 
    client.DefaultRequestHeaders.Authorization
        = new AuthenticationHeaderValue("Bearer", authResult.AccessToken);
    client.DefaultRequestHeaders.Accept
        .Add(new MediaTypeWithQualityHeaderValue("application/json"));
 
    var response = await client.GetAsync("ApiForServiceData");
 
    if (response.IsSuccessStatusCode)
    {
        Console.WriteLine(await response.Content.ReadAsStringAsync());
    }
}

This is the exact same client credentials flow underneath, ConfidentialClientApplicationBuilder is one of the building blocks Microsoft.Identity.Web itself uses internally. The difference is entirely about who is holding the reins, here the console app owns building the client, requesting the token, and attaching it to the HTTP call, rather than a framework doing that plumbing for you inside a request pipeline.

{
  "AzureADServiceApi": {
    "ClientId": "b178f3a5-7588-492a-924f-72d7887b7e48",
    // ClientSecret is in User Secrets, not here
    "Authority": "https://login.microsoftonline.com/7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ApiBaseAddress": "https://localhost:44324",
    "Scope": "api://b178f3a5-7588-492a-924f-72d7887b7e48/.default"
  }
}

The Scope value follows the same /.default convention covered in the previous article, request whatever application permissions have already been granted for this client rather than naming individual scopes. Notice the client secret is deliberately absent from appsettings.json and loaded from User Secrets during development instead, that separation is not optional, it is the entire point of keeping a secret out of anything that gets checked into source control.

What the Token Actually Contains

It’s worth actually decoding the token you get back at least once, rather than trusting that the flow worked because the API call returned data. Here is what a successful application token looks like once decoded.

{
  "iss": "https://login.microsoftonline.com/7ff95b15-dc21-4ba6-bc92-824856578fc1/v2.0",
  "iat": 1648363449,
  "nbf": 1648363449,
  "exp": 1648367349,
  "azp": "b178f3a5-7588-492a-924f-72d7887b7e48",
  "azpacr": "1",
  "oid": "3952ce95-8b14-47b4-b3e6-2a5521d35ed1",
  "roles": [
    "access_as_application",
    "service-api"
  ],
  "sub": "3952ce95-8b14-47b4-b3e6-2a5521d35ed1",
  "tid": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
  "ver": "2.0"
}

Notice there is no name, no email, no upn, none of the user-identifying claims you would expect in a delegated token, because there genuinely is no user here. The roles array is what the API’s authorization policy checks against, azp confirms which specific client this token was issued to, and azpacr confirming a value of 1 means it authenticated with a client secret rather than as an unauthenticated public client. If you ever see a token missing the roles claim entirely despite the App Role being configured, that almost always traces back to missing admin consent on the API permission, not a bug in the client code requesting the token.

Console Apps Are the Weakest Place to Hold This Secret

The source material is upfront about this and it is worth repeating plainly: a console application is close to the worst place to hold a client secret, since anyone with access to the machine or the compiled binary has a reasonable shot at extracting it. This pattern is fine for local testing and development, and it is a poor fit for anything running unattended in production.

For a real daemon deployment, host the client somewhere that supports a managed identity, an Azure Function, an App Service, a container running in Azure, and keep the actual secret or certificate in Key Vault, accessed through that managed identity rather than loaded from local configuration. That removes the secret from the client’s own filesystem and configuration entirely, since the managed identity itself is what authenticates to Key Vault, with no credential you have to store or rotate by hand on the client side.

Between a client secret and a certificate for the confidential client itself, prefer the certificate where your tooling supports it comfortably. A certificate is harder to leak accidentally through a config dump or a log line, and it supports a longer rotation window without needing a standing plaintext secret sitting in Key Vault at all.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading