.NET Core  

How to Secure .NET 6+ Apps with Azure Key Vault and Clean Architecture

In modern .NET development, keeping secrets like connection strings in appsettings.json is a security risk, especially in production. Thankfully, Azure Key Vault offers a secure way to manage secrets, certificates, and keys.

But what if you want your .NET app to seamlessly pull secrets from Key Vault without scattering Vault URIs or writing boilerplate code everywhere?

Let’s design a clean, extensible solution using Clean Architecture principles, Managed Identity, and caching + fallback support — all in .NET 6+.

Goal

We want to support the following in appsettings.json.

{
  "ConnectionStrings": {
    "Default": "@KeyVault:DbConnectionString"
  }
}

Our application should.

  • Automatically detect @KeyVault: markers
  • Resolve the actual secret from Azure Key Vault
  • Cache the secret to avoid repeated fetches
  • Fall back to local settings when Key Vault is unavailable

Architectural Overview

Layers

  • Core Layer – Interface for resolving secrets
  • Infrastructure Layer – Key Vault integration
  • Configuration Bootstrap – Replaces Key Vault markers at startup

Implementation

1. Marker Convention

public static class KeyVaultMarker
{
    public const string Prefix = "@KeyVault:";

    public static bool IsKeyVaultReference(string value) =>
        value?.StartsWith(Prefix) == true;

    public static string ExtractSecretName(string value) =>
        value?.Substring(Prefix.Length);
}

2. Secret Resolver Interface

public interface ISecretResolver { Task<string?> ResolveAsync(string keyVaultMarker); }

3. Azure Key Vault Implementation

public class AzureKeyVaultResolver : ISecretResolver
{
    private readonly SecretClient _secretClient;
    private readonly ILogger<AzureKeyVaultResolver> _logger;
    private readonly IMemoryCache _cache;

    public AzureKeyVaultResolver(
        SecretClient secretClient,
        IMemoryCache cache,
        ILogger<AzureKeyVaultResolver> logger)
    {
        _secretClient = secretClient;
        _cache = cache;
        _logger = logger;
    }

    public async Task<string?> ResolveAsync(string keyVaultMarker)
    {
        if (!KeyVaultMarker.IsKeyVaultReference(keyVaultMarker))
            return keyVaultMarker;

        var secretName = KeyVaultMarker.ExtractSecretName(keyVaultMarker);
        if (string.IsNullOrWhiteSpace(secretName))
            return null;

        if (_cache.TryGetValue(secretName, out string cached))
            return cached;

        try
        {
            var response = await _secretClient.GetSecretAsync(secretName);
            var secret = response.Value?.Value;

            _cache.Set(secretName, secret, TimeSpan.FromMinutes(10));
            return secret;
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to resolve Key Vault secret: {SecretName}", secretName);
            return null;
        }
    }
}

4. Configuration Replacer

public static class ConfigurationSecretReplacer
{
    public static async Task ReplaceKeyVaultReferencesAsync(IConfiguration configuration, ISecretResolver resolver)
    {
        foreach (var kvp in configuration.AsEnumerable())
        {
            if (KeyVaultMarker.IsKeyVaultReference(kvp.Value))
            {
                var resolved = await resolver.ResolveAsync(kvp.Value);

                if (resolved != null)
                {
                    var configRoot = (IConfigurationRoot)configuration;
                    var provider = configRoot.Providers.FirstOrDefault();
                    provider?.Set(kvp.Key, resolved);
                }
            }
        }
    }
}

5. Register in the Program.cs (.NET 6+)

builder.Services.AddMemoryCache();
builder.Services.AddSingleton<ISecretResolver, AzureKeyVaultResolver>();

builder.Host.ConfigureServices(async context =>
{
    var config = context.Configuration;
    var credential = new DefaultAzureCredential();

    var vaultUri = config["KeyVault:Uri"];
    var secretClient = new SecretClient(new Uri(vaultUri), credential);

    context.Services.AddSingleton(secretClient);

    var serviceProvider = context.Services.BuildServiceProvider();
    var resolver = serviceProvider.GetRequiredService<ISecretResolver>();

    await ConfigurationSecretReplacer.ReplaceKeyVaultReferencesAsync(config, resolver);
});

Bonus: Local Fallback Support

You can enhance the AzureKeyVaultResolver to first try Key Vault, and if that fails (e.g., local dev without cloud access), load from environment variables or a local secrets file.

This makes the app resilient and dev-friendly.

Clean Architecture Benefits

  • Separation of concerns: Azure SDK code is isolated in the infrastructure layer
  • Testable: You can mock ISecretResolver easily
  • Swappable: Replace Key Vault with AWS Secrets Manager or a local file
  • Zero clutter: No need to hardcode secret names or vault URIs

Tools & Packages Used

Conclusion

This approach helps you,

  • Build secure-by-default applications
  • Improve dev productivity by automating secret resolution
  • Stay aligned with modern best practices