.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. Azure Key Vault Implementation

public static class KeyVaultSecretResolver
{
    private static readonly TimeSpan DefaultCacheDuration = TimeSpan.FromMinutes(30);
    private static readonly ConcurrentDictionary<string, CachedSecret> _cache = new();
    private static bool _isResolved = false;

    public static async Task ResolveKeyVaultSecretsAsync(this WebApplicationBuilder builder, TimeSpan? cacheDuration = null)
    {
        if (_isResolved) return;
        _isResolved = true;

        var config = builder.Configuration;
        var logger = builder.Logging.CreateLogger("KeyVaultSecretResolver");

        var vaultUri = Environment.GetEnvironmentVariable("AZURE_KEYVAULT_URI");
        if (string.IsNullOrWhiteSpace(vaultUri))
        {
            logger.LogWarning("AZURE_KEYVAULT_URI not set. Skipping Key Vault integration.");
            return;
        }

        var client = new SecretClient(new Uri(vaultUri), new DefaultAzureCredential());
        var duration = cacheDuration ?? DefaultCacheDuration;

        foreach (var kv in config.AsEnumerable())
        {
            var secretName = KeyVaultMarker.ExtractSecretName(kv.Value);
            if (string.IsNullOrWhiteSpace(secretName)) continue;

            if (_cache.TryGetValue(secretName, out var cached) && !cached.IsExpired(duration))
            {
                config[kv.Key] = cached.Value;
                logger.LogDebug("Used cached secret for '{SecretName}'", secretName);
                continue;
            }

            try
            {
                var response = await client.GetSecretAsync(secretName);
                var secret = response.Value?.Value ?? string.Empty;

                config[kv.Key] = secret;
                _cache[secretName] = new CachedSecret(secret);
                logger.LogInformation("Resolved secret '{SecretName}' from Key Vault.", secretName);
            }
            catch (RequestFailedException ex)
            {
                logger.LogWarning("Failed to get secret '{SecretName}' from Key Vault: {Message}", secretName, ex.Message);

                var fallback = config[secretName] ?? Environment.GetEnvironmentVariable(secretName);
                if (!string.IsNullOrEmpty(fallback))
                {
                    config[kv.Key] = fallback;
                    _cache[secretName] = new CachedSecret(fallback);
                    logger.LogInformation("Fallback: Resolved secret '{SecretName}' locally.", secretName);
                }
                else
                {
                    logger.LogError("Secret '{SecretName}' not found in Key Vault or local fallback.", secretName);
                }
            }
            catch (Exception ex)
            {
                logger.LogError(ex, "Unexpected error resolving secret '{SecretName}'", secretName);
            }
        }
    }

    private class CachedSecret
    {
        public string Value { get; }
        public DateTime RetrievedAt { get; }

        public CachedSecret(string value)
        {
            Value = value;
            RetrievedAt = DateTime.UtcNow;
        }

        public bool IsExpired(TimeSpan duration) =>
            DateTime.UtcNow - RetrievedAt > duration;
    }
}

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

await builder.ResolveKeyVaultSecretsAsync(); // default 30 min cache

// Optional custom duration
await builder.ResolveKeyVaultSecretsAsync(TimeSpan.FromMinutes(15));

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.

Highlights

  • Zero code changes for individual services
  • Testable and restart-tolerant
  • Secrets never touch appsettings.json
  • Fully compatible with Azure App Service, Kubernetes, or VMs

Summary

If you're using Azure Key Vault in .NET apps and want:

  • Clean config-based integration
  • Async secret resolution
  • Fallback support and caching
  • No hardcoded vault URIs or custom DI wiring

This solution is ready for production.

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