Using ASP.NET Core with Azure Key Vault

Azure Key Vault is the standard way to keep secrets, connection strings and certificates out of appsettings.json and out of environment variables. The setup Microsoft documents works fine once an application is deployed to Azure, but on a developer’s own machine it often falls apart in small, annoying ways. This article walks through a setup that works cleanly in both places, using a pattern from Damien Bod’s blog.

The starting point is DefaultAzureCredential, which is the credential type most tutorials point you to. It looks convenient because it tries several credential sources in sequence, but on a shared development machine it becomes a source of constant friction.

A typical DefaultAzureCredential access error on a developer machine
A typical DefaultAzureCredential access error on a developer machine

If you manage multiple Azure accounts or work across several client tenants in Visual Studio, you will recognise this error. DefaultAzureCredential picks up whichever account is currently selected as the active filter in Visual Studio or Azure CLI, and if that account does not have access to the target tenant or the Key Vault, the call fails. Switching accounts back and forth every time you touch a different project gets old fast, and new team members waste time figuring out why their local run does not connect.

The fix is to stop depending on whatever account happens to be logged into Visual Studio, and instead use a dedicated application client secret through ChainedTokenCredential. A new Azure App registration is created purely for local development access, a client secret is generated for it, and that secret is stored in dotnet user secrets rather than in any file that could end up in source control.

A dedicated App registration created for local development access to Key Vault
A dedicated App registration created for local development access to Key Vault

Once the app registration exists, it needs a role assignment on the Key Vault itself. The application’s service principal is given the appropriate RBAC role, for example Key Vault Secrets User, scoped to that vault.

RBAC role assignment on the Key Vault for the development app registration's service principal
RBAC role assignment on the Key Vault for the development app registration’s service principal

With the client secret sitting in user secrets and the role assignment in place, every developer on the team can connect to the same Key Vault without anyone touching RBAC settings per person. When a new developer joins, you hand them the user secrets file (or the values to paste into it) and they are working within minutes, with no dependency on their personal Azure account or which tenant filter is active in Visual Studio.

Setting up local development credentials

A handful of NuGet packages cover most Key Vault integration scenarios in ASP.NET Core. Which ones you need depends on whether you are reading secrets, certificates, or wiring the vault in as a configuration source.

  • Azure.Extensions.AspNetCore.Configuration.Secrets
  • Azure.Identity
  • Azure.Security.KeyVault.Certificates
  • Azure.Security.KeyVault.Secrets

The credential logic itself lives in a small static class. It returns a ManagedIdentityCredential in production, wrapped in a ChainedTokenCredential, and falls back to a ClientSecretCredential built from configuration values when running locally. Notice the extra branch for a missing tenant ID: that path uses AzureCliCredential, which is useful when running in a DevOps pipeline that has already authenticated via az login rather than through a stored secret.

using Azure.Identity;
 
namespace DevelopmentAspNetCoreKeyVault;
 
public static class AppAccessCredentials
{
    public static ChainedTokenCredential GetChainedTokenCredentials(IConfiguration configuration, bool isDevelopment)
    {
        if (!isDevelopment)
        {
            // Use a system assigned managed identity on production deployments
            return new ChainedTokenCredential(new ManagedIdentityCredential());
        }
        else // dev env
        {
            var tenantId = configuration.GetValue<string>("EntraId:TenantId", string.Empty);
            var clientId = configuration.GetValue<string>("EntraId:ClientId", string.Empty);
            var clientSecret = configuration.GetValue<string>("EntraId:ClientSecret", string.Empty);
 
            var options = new TokenCredentialOptions
            {
                AuthorityHost = AzureAuthorityHosts.AzurePublicCloud
            };
 
            if (string.IsNullOrEmpty(tenantId)) // DevOps
            {
                // Use DefaultAzureCredential if AzureCliCredential is not used in your DevOps
                return new ChainedTokenCredential(new AzureCliCredential());
            }
 
            // https://docs.microsoft.com/dotnet/api/azure.identity.clientsecretcredential
            var devClientSecretCredential = new ClientSecretCredential(
                tenantId, clientId, clientSecret, options);
 
            var chainedTokenCredential = new ChainedTokenCredential(devClientSecretCredential);
 
            return chainedTokenCredential;
        }
    }
}

The isDevelopment flag comes straight from IHostEnvironment.IsDevelopment(), so no extra configuration switch is needed to decide which branch runs. In production this collapses to a single ManagedIdentityCredential call, which is exactly the behaviour you want: no secrets to rotate, no client ID to manage, and access controlled entirely through RBAC on the managed identity.

The three EntraId values referenced above come from dotnet user secrets, not from appsettings.json. A typical secrets.json for this project looks like this.

{
  "EntraId": {
    // aspnetcore-keyvault-development-access
    "TenantId": "--tenant_id--",
    "ClientId": "--client_id--",
    "ClientSecret": "--secret--"
  }
}

Keeping this in user secrets means it never gets committed to git by accident, and each developer’s secrets store lives outside the project folder on their own machine. It is still a shared secret in the sense that the whole team uses the same app registration, so treat it the same way you would treat any shared credential: rotate it periodically and remove it entirely once real production credentials are in place.

Reading a secret directly with SecretClient

Sometimes you just want one secret value without wiring the vault into the configuration pipeline. The Azure SDK’s SecretClient class handles this directly, and it takes the same chained credential built above.

// Azure SDK direct
var client = new SecretClient(new Uri(_configuration["AzureKeyVaultEndpoint"]!),
    AppAccessCredentials.GetChainedTokenCredentials(_configuration,
        _hostEnvironment.IsDevelopment()));
 
var secret = await client.GetSecretAsync("demosecret");
DemoSecret = secret!.Value.Value;

This call goes over the network to Key Vault every time it runs, so avoid calling it inside a hot path such as a request handler that executes on every page load. It is better suited to startup code, a background refresh job, or a page that specifically needs to demonstrate or test the direct-read path.

Wiring Key Vault into IConfiguration

The more common approach, and the one worth reaching for by default, is to add Key Vault as a configuration source using AddAzureKeyVault. This keeps every setting, whether it comes from appsettings.json, environment variables or the vault, accessible through the same IConfiguration interface, so the rest of the application does not need to know or care where a value originated.

var keyVault = builder.Configuration["AzureKeyVaultEndpoint"];
if(!string.IsNullOrEmpty(keyVault))
{
    builder.Configuration.AddAzureKeyVault(
    new Uri($"{builder.Configuration["AzureKeyVaultEndpoint"]}"),
    AppAccessCredentials.GetChainedTokenCredentials(builder.Configuration,
        builder.Environment.IsDevelopment()));
}

The null check on the endpoint matters more than it looks. Without it, an environment that has no Key Vault configured (a local test run, for instance) throws at startup instead of simply skipping the Key Vault source. Wrap this in the check shown above so the application can still start when the vault is not configured, which is useful for unit tests and for anyone spinning up the project for the first time without full Azure access.

Once this is registered, secrets from the vault show up as ordinary configuration values, indistinguishable from anything in appsettings.json.

// ASP.NET Core configuration
// From from key vault using ASP.NET Core configuration integration
// Or from user secrets if offline, or fast startup is required
DemoSecretConfig = _configuration["demosecret"];

A common naming gotcha here: Key Vault secret names cannot contain colons, so if you are used to the standard ASP.NET Core convention of Section:Key for nested configuration, you need to use a double dash instead, for example Section–Key. The Key Vault configuration provider automatically converts double dashes back to colons when it loads the value into IConfiguration.

Falling back to user secrets for offline work

Every call into AddAzureKeyVault happens at startup and requires network access. If you are on a flight, on a slow connection, or just restarting the app repeatedly while debugging, constantly reaching out to Key Vault adds real friction. For that kind of work, it is often simpler to temporarily source the same configuration keys from dotnet user secrets instead, then switch back to the vault before committing or deploying.

What this pattern is not for

This client-secret-based approach is deliberately scoped to local development. Client secrets expire, they need to be rotated, and having a shared secret across a team is a weaker security posture than per-identity access. Production, staging and any shared cloud environment should use a system-assigned managed identity instead, which removes the secret entirely and ties access to the identity of the deployed resource itself.

It is also worth being honest about the trade-off in the ChainedTokenCredential fallback: if you forget to remove the DevOps branch or leave AzureCliCredential in a pipeline that should really be using a service connection or a managed identity, you end up with a silent dependency on whoever is logged into the Azure CLI on that build agent. Review this class whenever you promote a pipeline from experimental to production use.

Leave a Reply

Discover more from Behind the Stack

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

Continue reading