Implementing secure Microsoft Graph application clients in ASP.NET Core

Application clients for Microsoft Graph are used when there is no signed in user in the picture. A background job that syncs users, a scheduled script that disables stale accounts, or a service that reads directory data all need this kind of client. In this article I will walk through three ways to set up a Graph application client in an ASP.NET Core or .NET application, and share when I would reach for each one.

A working sample for all three approaches is available on GitHub at github.com/damienbod/MicrosoftGraphAppToAppSecurity. I would recommend cloning it alongside this article since some of the wiring, especially the dependency injection setup, is easier to follow with the full project open.

Three ways to set up an application client

Microsoft Graph app-to-app access, meaning application permissions rather than delegated permissions, can be initialized in three different ways. All three only work from a trusted host, since application permissions grant access without any user consent in the loop. The three implementation types are:

  • Managed identities, where Azure handles the credential for you
  • Azure SDK and Graph SDK used directly with client credentials (a secret or a certificate)
  • Microsoft.Identity.Client (MSAL) to acquire a token, which is then used directly against Graph or wrapped in a GraphServiceClient using a DelegateAuthenticationProvider

None of this applies to delegated clients. If you are calling Graph on behalf of a signed in user in an ASP.NET Core app, you would normally reach for Microsoft.Identity.Web instead. This article is specifically about the app-only, no-user-involved case.

Option 1: Managed identities

Of the three approaches, managed identities are the most secure. No secret or certificate is created, stored, or shared anywhere, so there is nothing to leak and nothing to rotate. The catch is that a managed identity only works for resources running in the same Azure tenant, so this option is off the table if your workload runs outside Azure or needs to reach a different tenant.

Setup

In the sample, a web application is deployed to an Azure App Service, and a system assigned managed identity is created for that resource. The identity is tied to the lifecycle of the resource: delete the App Service and the managed identity, along with any Graph roles assigned to it, disappears with it. Only that specific Azure resource can use the identity, which is a useful property when you are reasoning about blast radius.

System assigned managed identity created for the Azure App Service
System assigned managed identity created for the Azure App Service

Once the resource exists and the managed identity is enabled, the Graph application permissions (also called app roles) have to be assigned to that identity. This step happens outside the C# code, using PowerShell or the Azure CLI, because there is no portal UI that lets you assign Graph app roles to a managed identity directly.

PowerShell scripting

The script below, adapted from a Microsoft blog, finds the managed identity’s service principal by display name, looks up the Microsoft Graph service principal (the well known app ID 00000003-0000-0000-c000-000000000000 is Graph itself), and then creates an app role assignment that grants User.Read.All.

$TenantID = "<your-tenant-id>"
$DisplayNameServicePrincpal ="<your-azure-app-registration-or-other-azure-resource>"
$GraphAppId = "00000003-0000-0000-c000-000000000000"
$PermissionName = "User.Read.All"
 
Connect-AzureAD -TenantId $TenantID
 
$sp = (Get-AzureADServicePrincipal -Filter "displayName eq '$DisplayNameServicePrincpal'")
 
Write-Host $sp
 
$GraphServicePrincipal = Get-AzureADServicePrincipal -Filter "appId eq '$GraphAppId'"
 
$AppRole = $GraphServicePrincipal.AppRoles | Where-Object {$_.Value -eq $PermissionName -and $_.AllowedMemberTypes -contains "Application"}
 
New-AzureAdServiceAppRoleAssignment -ObjectId $sp.ObjectId -PrincipalId $sp.ObjectId -ResourceId $GraphServicePrincipal.ObjectId -Id $AppRole.Id

Run this once per environment after the managed identity is created. A mistake I have seen people make here is filtering by the wrong display name: the managed identity’s service principal name usually matches the Azure resource name, not the App Service’s friendly display name in the portal, so double check the name in the Enterprise applications blade before running the script. The AzureAD PowerShell module used here is on a deprecation path in favor of Microsoft Graph PowerShell, so if you are setting this up fresh today it is worth checking whether the Graph cmdlets are a better fit for your pipeline.

You can confirm the assignment worked by opening the Enterprise applications blade in the Azure portal and filtering for managed identities.

Enterprise applications filtered for managed identities
Enterprise applications filtered for managed identities

Clicking into the identity and checking its permissions should show the Graph application permission you assigned.

User.Read.All application permission assigned to the managed identity
User.Read.All application permission assigned to the managed identity

Implementing the client

On the code side, the client is built using Azure.Identity and the Graph SDK. There are two credential paths: one for production and any other real Azure deployment, and one for local development, since a managed identity does not exist when you run the app on your own machine. The GetGraphClientWithManagedIdentityOrDevClient method below returns a GraphServiceClient wired up for whichever environment the app is running in.

using Azure.Identity;
using Microsoft.Graph;
 
namespace GraphManagedIdentity;
 
public class GraphApplicationClientService
{
    private readonly IConfiguration _configuration;
    private readonly IHostEnvironment _environment;
    private GraphServiceClient? _graphServiceClient;
 
    public GraphApplicationClientService(IConfiguration configuration, IHostEnvironment environment)
    {
        _configuration = configuration;
        _environment = environment;
    }
 
    /// <summary>
    /// gets a singleton instance of the GraphServiceClient
    /// </summary>
    /// <returns></returns>
    public GraphServiceClient GetGraphClientWithManagedIdentityOrDevClient()
    {
        if (_graphServiceClient != null)
            return _graphServiceClient;
 
        string[] scopes = new[] { "https://graph.microsoft.com/.default" };
 
        var chainedTokenCredential = GetChainedTokenCredentials();
        _graphServiceClient = new GraphServiceClient(chainedTokenCredential, scopes);
 
        return _graphServiceClient;
    }
 
    private ChainedTokenCredential GetChainedTokenCredentials()
    {
        if (!_environment.IsDevelopment())
        {
            return new ChainedTokenCredential(new ManagedIdentityCredential());
        }
        else // dev env
        {
            var tenantId = _configuration["AzureAd:TenantId"];
            var clientId = _configuration.GetValue<string>("AzureAd:ClientId");
            var clientSecret = _configuration.GetValue<string>("AzureAd:ClientSecret");
 
            var options = new TokenCredentialOptions
            {
                AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
            };
 
            var devClientSecretCredential = new ClientSecretCredential(
                tenantId, clientId, clientSecret, options);
 
            var chainedTokenCredential = new ChainedTokenCredential(devClientSecretCredential);
 
            return chainedTokenCredential;
        }
    }
}

Notice the scope used is https://graph.microsoft.com/.default rather than a specific permission name. That is the convention for the client credentials flow: the .default scope tells Azure AD to issue a token with whatever application permissions have already been granted and admin consented on the app registration or managed identity, rather than requesting a specific permission at token time. The service is registered as a singleton so the GraphServiceClient and its underlying token cache are reused across requests instead of being rebuilt every time.

builder.Services.AddSingleton<GraphApplicationClientService>();
builder.Services.AddScoped<AadGraphSdkApplicationClient>();

With that registered in the IoC container, the service can be injected anywhere it is needed. A consumer might look like this:

using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
using Microsoft.Graph;
using System.Security.Cryptography.X509Certificates;
 
namespace GraphClientCrendentials;
 
public class AadGraphSdkApplicationClient
{
    private readonly IConfiguration _configuration;
    private readonly GraphApplicationClientService _graphService;
 
    public AadGraphSdkApplicationClient(IConfiguration configuration, GraphApplicationClientService graphService)
    {
        _configuration = configuration;
        _graphService = graphService;
    }
 
    public async Task<int> GetUsersAsync()
    {
        var graphServiceClient = _graphService.GetGraphClientWithClientSecretCredential();
 
        IGraphServiceUsersCollectionPage users = await graphServiceClient.Users
            .Request()
            .GetAsync();
 
        return users.Count;
    }
}

Calling GetUsersAsync returns the count of users visible under whatever permission was granted, User.Read.All in this example. If the app role assignment or admin consent step was missed, this call fails with a 403 Forbidden rather than an empty list, which is usually the first place to check when a working local setup suddenly stops working after deployment.

Dev setup

Since managed identities only exist once deployed to Azure, local development falls back to a regular Azure App registration using the OAuth client credentials flow. An enterprise application is automatically created alongside the app registration when you add the Graph application permission and grant admin consent.

Graph application permission added to the single tenant App registration
Graph application permission added to the single tenant App registration

The ChainedTokenCredential picks up the tenant ID and client ID from appsettings, and the client secret from user secrets, never from appsettings.json itself. The client uses the OAuth client credentials flow to get a token. For day to day development a secret is fine for simplicity, but if you want tighter security even locally, you can switch to a certificate and pull the secret or certificate straight from an Azure Key Vault instead of user secrets.

  "AzureAd": {
    "TenantId": "7ff95b15-dc21-4ba6-bc92-824856578fc1",
    "ClientId": "3606b25d-f670-4bab-ab70-437460143d89"
    //"ClientSecret": "add secret to the user secrets",
    //"CertificateName": "[Or instead of client secret: Enter here the name of a certificate (from the user cert store) as registered with your application]",
    //"Certificate": {
    //  "SourceType": "KeyVault",
    //  "KeyVaultUrl": "<VaultUri>",
    //  "KeyVaultCertificateName": "<CertificateName>"
    //}
  },

Keeping the client secret out of appsettings.json and in user secrets is not optional in my view. It is a small habit that avoids a very common mistake: someone commits the config file with a real secret in it, and now that secret has to be rotated regardless of whether the repository is public or private.

Option 2: Azure SDK and Graph SDK directly

The second approach skips managed identities altogether and configures the Graph SDK client credential directly, using either a secret or a certificate. This is the right choice when the client needs to run outside the Azure tenant that hosts the target directory, since a managed identity cannot cross tenant boundaries. Microsoft’s own guidance is to prefer a certificate over a secret here, typically stored in Azure Key Vault, and this uses client assertions under the hood rather than a plain shared secret.

Configured with a secret, the setup looks like this:

private GraphServiceClient GetGraphClientWithClientSecretCredential()
{
	string[] scopes = new[] { "https://graph.microsoft.com/.default" };
	var tenantId = _configuration["AzureAd:TenantId"];
 
	// Values from app registration
	var clientId = _configuration.GetValue<string>("AzureAd:ClientId");
	var clientSecret = _configuration.GetValue<string>("AzureAd:ClientSecret");
 
	var options = new TokenCredentialOptions
	{
		AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
	};
 
	var clientSecretCredential = new ClientSecretCredential(
		tenantId, clientId, clientSecret, options);
 
	return new GraphServiceClient(clientSecretCredential, scopes);
}

This returns a GraphServiceClient backed by a ClientSecretCredential. It is the quickest way to get something working, which is exactly why it tends to end up in production when it should not: secrets have to be rotated on a schedule, and every rotation is an opportunity for an outage if the new secret is not deployed everywhere in time.

For production, the certificate based version is generally the safer choice. This variant pulls the certificate out of Key Vault at runtime instead of relying on a certificate installed in the local machine store:

private async Task<GraphServiceClient> GetGraphClientWithClientCertificateCredentialAsync()
{
	string[] scopes = new[] { "https://graph.microsoft.com/.default" };
	var tenantId = _configuration["AzureAd:TenantId"];
 
	var options = new TokenCredentialOptions
	{
		AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
	};
 
	// Values from app registration
	var clientId = _configuration.GetValue<string>("AzureAd:ClientId");
 
	var certififacte = await GetCertificateAsync();
	var clientCertificateCredential = new ClientCertificateCredential(
		tenantId, clientId, certififacte, options);
 
	// var clientCertificatePath = _configuration.GetValue<string>("AzureAd:CertificateName");
	// var clientCertificateCredential = new ClientCertificateCredential(
	//    tenantId, clientId, clientCertificatePath, options);
 
	return new GraphServiceClient(clientCertificateCredential, scopes);
}
 
private async Task<X509Certificate2> GetCertificateAsync()
{
	var identifier = _configuration["AzureAd:ClientCertificates:0:KeyVaultCertificateName"];
 
	if (identifier == null)
		throw new ArgumentNullException(nameof(identifier));
 
	var vaultBaseUrl = _configuration["AzureAd:ClientCertificates:0:KeyVaultUrl"];
	if(vaultBaseUrl == null)
		throw new ArgumentNullException(nameof(vaultBaseUrl));
 
	var secretClient = new SecretClient(vaultUri: new Uri(vaultBaseUrl), credential: new DefaultAzureCredential());
 
	// Create a new secret using the secret client.
	var secretName = identifier;
	KeyVaultSecret secret = await secretClient.GetSecretAsync(secretName);
 
	var privateKeyBytes = Convert.FromBase64String(secret.Value);
 
	var certificateWithPrivateKey = new X509Certificate2(privateKeyBytes,
		string.Empty, X509KeyStorageFlags.MachineKeySet);
 
	return certificateWithPrivateKey;
}

GetCertificateAsync fetches the certificate from Key Vault as a secret (certificates stored via the Key Vault certificate API are also retrievable as base64 encoded secrets), converts it to raw bytes, and builds an X509Certificate2 from it using DefaultAzureCredential to authenticate to the vault itself. One thing worth watching here: X509KeyStorageFlags.MachineKeySet requires the process identity to have permission to write to the machine key store, which can quietly fail or behave differently across Windows, Linux containers, and different App Service tiers. If you hit certificate loading errors that only show up in one environment, this flag combination is usually where I start looking. A common practical pattern is secret for development, certificate for production, since a leaked secret in a local dev environment is a much smaller blast radius than one in production.

Option 3: MSAL and Microsoft.Identity.Client

The third approach uses Microsoft.Identity.Client (or its higher level wrapper Microsoft.Identity.Web) instead of the Graph SDK’s built in credential types. ConfidentialClientApplicationBuilder creates an IConfidentialClientApplication, which can use either a secret or a certificate to acquire tokens, same as the previous option, just through a different API surface.

With a secret:

 var app = ConfidentialClientApplicationBuilder.Create(config.ClientId)
    .WithClientSecret(config.ClientSecret)
    .WithAuthority(new Uri(config.Authority))
    .Build();
 
app.AddInMemoryTokenCache();

Or with a certificate and client assertions:

 var app = ConfidentialClientApplicationBuilder.Create(config.ClientId)
    .WithCertificate(certificate)
    .WithAuthority(new Uri(config.Authority))
    .Build(); 
  
app.AddInMemoryTokenCache();

AddInMemoryTokenCache is worth calling out. Without it, MSAL still works correctly but reacquires a token on every single call, which adds latency and puts unnecessary load on Azure AD. The in-memory cache lets MSAL reuse a valid token until it is close to expiry, which is what you want in any service that calls Graph more than once.

Once you have the IConfidentialClientApplication, a GraphServiceClient can be built around it using a DelegateAuthenticationProvider:

GraphServiceClient graphServiceClient =
    new GraphServiceClient("https://graph.microsoft.com/V1.0/", 
        new DelegateAuthenticationProvider(async (requestMessage) =>
        {
            // Retrieve an access token for Microsoft Graph (gets a fresh token if needed).
            AuthenticationResult result = await app.AcquireTokenForClient(scopes)
                .ExecuteAsync();
 
            // Add the access token in the Authorization header of the API request.
            requestMessage.Headers.Authorization =
                new AuthenticationHeaderValue("Bearer", result.AccessToken);
        }));
}

This wires AcquireTokenForClient into every outgoing Graph request, so a fresh or cached token is attached automatically without you having to manage the Authorization header by hand elsewhere in the code. The catch is that DelegateAuthenticationProvider is on its way out. The current Graph SDK guidance favors implementing IAuthenticationProvider directly or using one of the built in credential based constructors shown in Option 1 and 2, since those integrate more cleanly with Azure.Identity and are easier to unit test. I would only reach for the DelegateAuthenticationProvider pattern if you are maintaining an older codebase that already uses MSAL directly and a full rewrite is not worth the risk right now.

Which option should you pick

Deciding between these three often comes down to where the workload runs and how much control you have over its infrastructure. Managed identity is the right default whenever the client lives inside Azure and only ever needs to reach Graph in its own tenant. There is no secret to store, no certificate to renew, and no rotation calendar to maintain, which removes an entire category of operational risk.

The client credentials flow, whether through the Graph SDK directly or through MSAL, is what you fall back on when the client is outside Azure, running on premises, in another cloud, or needs to reach a directory in a different tenant. Between a secret and a certificate for that flow, I would default to a certificate for anything customer facing or production grade, and reserve secrets for local development or short lived proof of concept work. Certificates are harder to accidentally paste into a Slack message or commit to a repository, and Key Vault backed certificate rotation can be automated in a way that shared secrets generally are not.

For rotation strategy specifically, plan for it before you need it. Managed identities sidestep the problem entirely, which is one more reason to prefer them when the option is available. For certificate based client credentials, Key Vault can auto-rotate certificates ahead of expiry if you configure a rotation policy on the certificate itself, and your application code should be reading the certificate from Key Vault at startup or on a refresh interval rather than caching it forever, so a rotated certificate gets picked up without a redeploy. For secrets, treat the expiry date on the app registration as a hard calendar reminder, because an expired client secret fails silently until the next token acquisition, often in production, at the worst possible time.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading