Learnixo

.NET & C# Development · Lesson 229 of 229

Secrets Management in .NET — From User Secrets to Azure Key Vault

The Problem: Secrets Live in Source Control

The most common secrets leak in .NET projects looks like this:

JSON
// appsettings.json — committed to git
{
  "ConnectionStrings": {
    "Database": "Server=prod-db.example.com;Database=orders;User Id=app;Password=Sup3rS3cr3t!"
  },
  "Jwt": {
    "Secret": "my-very-secret-key-12345"
  }
}

Every developer, every CI runner, and every contractor who clones this repository now has production database credentials. The credentials are in git history forever, even after you delete the file.

Even when teams avoid appsettings.json, they often fall back to environment variables — which get logged by deployment pipelines, exposed in process listings, and shared through .env files that end up in Slack.

This article covers the correct approach for each environment:

| Environment | Tool | Why | |---|---|---| | Local development | dotnet user-secrets | Secrets outside the repo, per-developer | | CI / staging | Environment variables from a vault | Short-lived, audited access | | Production | Azure Key Vault + Managed Identity | No credentials needed; full audit log |


Development: dotnet user-secrets

User secrets store configuration values in a JSON file on your local machine, outside the repository directory. They are not part of the project build output and are never committed to source control.

How It Works

When you initialize user secrets for a project, .NET generates a UserSecretsId GUID and stores it in your .csproj:

XML
<PropertyGroup>
    <UserSecretsId>8a4f2e1c-7b3d-4a9f-8c2e-1f5d3a8b7e4c</UserSecretsId>
</PropertyGroup>

Secrets are stored at:

  • Windows: %APPDATA%\Microsoft\UserSecrets\{UserSecretsId}\secrets.json
  • Linux/macOS: ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json

The secrets file is per-developer, per-project, and lives entirely outside the repository.

Setting Secrets

Bash
# Initialize (creates the GUID in .csproj)
dotnet user-secrets init

# Set individual secrets
dotnet user-secrets set "ConnectionStrings:Database" "Server=localhost;Database=orders_dev;Trusted_Connection=true"
dotnet user-secrets set "Jwt:Secret" "dev-only-secret-not-used-in-prod"
dotnet user-secrets set "ExternalApi:ApiKey" "dev-api-key-from-sandbox-account"

# List all secrets for the current project
dotnet user-secrets list

# Remove a secret
dotnet user-secrets remove "ExternalApi:ApiKey"

How ASP.NET Core Reads Them

User secrets are added to the configuration pipeline automatically in the Development environment:

C#
// This is what WebApplication.CreateBuilder does internally:
builder.Configuration
    .AddJsonFile("appsettings.json")
    .AddJsonFile($"appsettings.{env}.json", optional: true)
    .AddEnvironmentVariables()
    .AddUserSecrets(assembly, optional: true);  // Added only in Development

You access them identically to any other configuration:

C#
var connectionString = builder.Configuration.GetConnectionString("Database");
var jwtSecret = builder.Configuration["Jwt:Secret"];

What to Put in appsettings.json (safe — no secrets)

JSON
{
  "ConnectionStrings": {
    "Database": ""
  },
  "Jwt": {
    "Issuer": "https://auth.example.com",
    "Audience": "api.example.com",
    "Secret": ""
  },
  "ExternalApi": {
    "BaseUrl": "https://api.external.com",
    "ApiKey": ""
  }
}

Empty placeholders show developers what needs to be configured without revealing how. The README explains that the values come from user secrets. Never put dummy values like "change-me" — they end up in production.


Production: Azure Key Vault with Managed Identity

In production, the goal is zero secrets in the deployment: no connection strings in environment variables, no API keys in app configuration, no credentials in Kubernetes secrets. Azure Key Vault with Managed Identity achieves this.

How It Works

  1. Your Azure App Service / AKS pod is assigned a Managed Identity (system-assigned or user-assigned)
  2. The identity is granted Key Vault Secrets User role on your Key Vault
  3. Your application uses DefaultAzureCredential to authenticate as the managed identity — no credentials required
  4. Secrets are loaded from Key Vault on startup and merged into IConfiguration

No credentials ever appear in your code, your environment variables, or your deployment pipeline.

Setting Up Key Vault

Bash
# Create a Key Vault
az keyvault create \
  --name "yourapp-kv-prod" \
  --resource-group "yourapp-rg" \
  --location "eastus"

# Add secrets (Key Vault naming: use -- as the : separator for nested config)
az keyvault secret set \
  --vault-name "yourapp-kv-prod" \
  --name "ConnectionStrings--Database" \
  --value "Server=prod-db.example.com;..."

az keyvault secret set \
  --vault-name "yourapp-kv-prod" \
  --name "Jwt--Secret" \
  --value "$(openssl rand -base64 48)"

# Grant your App Service managed identity read access
az keyvault set-policy \
  --name "yourapp-kv-prod" \
  --object-id "$(az webapp identity show --name yourapp --resource-group yourapp-rg --query principalId -o tsv)" \
  --secret-permissions get list

Wiring Key Vault in Program.cs

C#
// Program.cs
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

var builder = WebApplication.CreateBuilder(args);

// Load Key Vault only outside Development.
// In Development: user-secrets are already loaded by the builder.
if (!builder.Environment.IsDevelopment())
{
    var keyVaultUri = new Uri(
        builder.Configuration["KeyVault:Uri"]
        ?? throw new InvalidOperationException("KeyVault:Uri is not configured"));

    // DefaultAzureCredential automatically uses:
    // - Managed Identity in Azure (App Service, AKS, etc.)
    // - Azure CLI credentials locally if you run with ASPNETCORE_ENVIRONMENT=Staging
    // - Visual Studio credentials as a fallback
    // No connection strings or secrets needed.
    builder.Configuration.AddAzureKeyVault(keyVaultUri, new DefaultAzureCredential());
}

// appsettings.json holds the Key Vault URI (not a secret):
// { "KeyVault": { "Uri": "https://yourapp-kv-prod.vault.azure.net/" } }

Secret Naming Convention

Key Vault secret names can only contain alphanumeric characters and hyphens. ASP.NET Core's AddAzureKeyVault maps -- in secret names to : in configuration keys:

| Key Vault Secret Name | IConfiguration Key | |---|---| | ConnectionStrings--Database | ConnectionStrings:Database | | Jwt--Secret | Jwt:Secret | | ExternalApi--PaymentGateway--ApiKey | ExternalApi:PaymentGateway:ApiKey |

This convention lets you access Key Vault secrets exactly like local configuration:

C#
var connectionString = builder.Configuration.GetConnectionString("Database");
// Returns the value from Key Vault in production, from user-secrets in dev

ISecretManager Abstraction

For testability and to decouple business logic from infrastructure, define an abstraction:

C#
namespace YourApp.Security;

/// <summary>
/// Provides access to application secrets.
/// In Development: reads from IConfiguration (user-secrets).
/// In Production: reads directly from Key Vault for non-startup secrets
///                that need runtime refresh (e.g., API keys, signing certificates).
/// </summary>
public interface ISecretManager
{
    Task<string> GetSecretAsync(string name, CancellationToken ct = default);
    Task<string?> GetSecretOrDefaultAsync(string name, CancellationToken ct = default);
}

Development Implementation (User Secrets via IConfiguration)

C#
namespace YourApp.Security;

/// <summary>
/// Development secret manager that reads from IConfiguration.
/// Suitable for local development only — do not register in production.
/// </summary>
public sealed class ConfigurationSecretManager(IConfiguration configuration) : ISecretManager
{
    public Task<string> GetSecretAsync(string name, CancellationToken ct = default)
    {
        var value = configuration[name]
            ?? throw new InvalidOperationException(
                $"Secret '{name}' is not configured. " +
                $"Run: dotnet user-secrets set \"{name}\" \"<value>\"");
        return Task.FromResult(value);
    }

    public Task<string?> GetSecretOrDefaultAsync(string name, CancellationToken ct = default)
        => Task.FromResult(configuration[name]);
}

Production Implementation (Azure Key Vault Direct)

For secrets that need to be fetched at runtime (not just at startup), use the Key Vault client directly:

C#
namespace YourApp.Security;

/// <summary>
/// Production secret manager that fetches secrets directly from Azure Key Vault.
/// Use for secrets needed at runtime, not just at startup configuration binding.
/// Includes a short in-memory cache to avoid throttling Key Vault on every request.
/// </summary>
public sealed class KeyVaultSecretManager(
    SecretClient secretClient,
    ILogger<KeyVaultSecretManager> logger) : ISecretManager
{
    // Cache secrets for 5 minutes to avoid hammering Key Vault
    private readonly MemoryCache _cache = new(new MemoryCacheOptions());
    private static readonly TimeSpan CacheDuration = TimeSpan.FromMinutes(5);

    public async Task<string> GetSecretAsync(string name, CancellationToken ct = default)
    {
        var value = await GetSecretOrDefaultAsync(name, ct);
        return value
            ?? throw new InvalidOperationException($"Secret '{name}' not found in Key Vault");
    }

    public async Task<string?> GetSecretOrDefaultAsync(string name, CancellationToken ct = default)
    {
        if (_cache.TryGetValue(name, out string? cached))
            return cached;

        try
        {
            var response = await secretClient.GetSecretAsync(name, cancellationToken: ct);
            var value    = response.Value.Value;

            _cache.Set(name, value, CacheDuration);
            return value;
        }
        catch (RequestFailedException ex) when (ex.Status == 404)
        {
            logger.LogWarning("Secret '{SecretName}' not found in Key Vault", name);
            return null;
        }
    }
}

Registration

C#
// Program.cs
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddSingleton<ISecretManager, ConfigurationSecretManager>();
}
else
{
    var kvUri = builder.Configuration["KeyVault:Uri"]!;
    builder.Services.AddSingleton(new SecretClient(new Uri(kvUri), new DefaultAzureCredential()));
    builder.Services.AddSingleton<ISecretManager, KeyVaultSecretManager>();
}

Secret Rotation Without Restarting

Hard-coded secrets that require a service restart to rotate are an operational liability. Key Vault versioning combined with IOptionsMonitor allows zero-downtime rotation.

Key Vault Secret Versioning

Key Vault maintains a full version history. When you rotate a secret, you create a new version — the previous version is not deleted and services bound to a specific version continue to work during the rotation window.

Bash
# Rotate a secret by creating a new version
az keyvault secret set \
  --vault-name "yourapp-kv-prod" \
  --name "ExternalApi--PaymentGateway--ApiKey" \
  --value "new-rotated-api-key-value"

# The old version is still accessible by its specific version ID
# New fetches without a version ID get the latest version automatically

SecretRotationService — Polling for Changes

C#
namespace YourApp.Security;

/// <summary>
/// Background service that polls Key Vault for secret version changes.
/// When a secret is rotated, it signals IOptionsMonitor subscribers
/// so the new value is picked up without a service restart.
///
/// Design notes:
///  - Polls every 15 minutes (configurable via SecretRotationOptions)
///  - Only fetches secrets listed in the watch list — avoids scanning the entire vault
///  - Logs a warning (not error) on transient Key Vault failures — rotation is best-effort
/// </summary>
public sealed class SecretRotationService(
    SecretClient secretClient,
    IOptionsMonitorCache<PaymentGatewayOptions> optionsCache,
    IConfiguration configuration,
    ILogger<SecretRotationService> logger) : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Key Vault names to watch (map to IOptions type names for invalidation)
        var watchList = new Dictionary<string, string>
        {
            ["ExternalApi--PaymentGateway--ApiKey"] = nameof(PaymentGatewayOptions)
        };

        var knownVersions = new Dictionary<string, string?>();

        while (!stoppingToken.IsCancellationRequested)
        {
            await Task.Delay(TimeSpan.FromMinutes(15), stoppingToken);

            foreach (var (secretName, optionsName) in watchList)
            {
                try
                {
                    var props = await secretClient.GetSecretAsync(secretName, cancellationToken: stoppingToken);
                    var currentVersion = props.Value.Properties.Version;

                    if (knownVersions.TryGetValue(secretName, out var lastVersion)
                        && lastVersion != currentVersion)
                    {
                        logger.LogInformation(
                            "Secret '{SecretName}' rotated from version {Old} to {New}. Invalidating options cache.",
                            secretName, lastVersion, currentVersion);

                        // Invalidate the IOptionsMonitor cache — next access re-reads from configuration
                        optionsCache.TryRemove(Options.DefaultName);
                    }

                    knownVersions[secretName] = currentVersion;
                }
                catch (Exception ex) when (ex is not OperationCanceledException)
                {
                    logger.LogWarning(ex,
                        "Failed to check rotation status for secret '{SecretName}'", secretName);
                }
            }
        }
    }
}

public sealed class PaymentGatewayOptions
{
    public string ApiKey  { get; set; } = string.Empty;
    public string BaseUrl { get; set; } = string.Empty;
}

Register the rotation service:

C#
// Program.cs (production only)
builder.Services.Configure<PaymentGatewayOptions>(
    builder.Configuration.GetSection("ExternalApi:PaymentGateway"));
builder.Services.AddHostedService<SecretRotationService>();

Consume with IOptionsMonitor so changes are picked up without restart:

C#
public sealed class PaymentGatewayClient(IOptionsMonitor<PaymentGatewayOptions> options)
{
    // IOptionsMonitor.CurrentValue is always the latest value.
    // When SecretRotationService invalidates the cache, the next access
    // to CurrentValue reflects the rotated secret.
    public string CurrentApiKey => options.CurrentValue.ApiKey;
}

Common Mistakes

Mistake 1: Logging Configuration on Startup

C#
// BAD: logs the full configuration on startup — exposes connection strings
var config = builder.Configuration.AsEnumerable()
    .Select(kv => $"{kv.Key} = {kv.Value}");
logger.LogInformation("Configuration: {Config}", string.Join(", ", config));

This single line sends every connection string, API key, and JWT secret to your log aggregator. Use this pattern instead:

C#
// GOOD: log only non-sensitive config keys
var safeConfig = builder.Configuration.AsEnumerable()
    .Where(kv => !IsSensitiveKey(kv.Key))
    .Select(kv => $"{kv.Key} = {kv.Value}");
logger.LogInformation("Configuration loaded: {Keys}", string.Join(", ", safeConfig));

static bool IsSensitiveKey(string key) =>
    key.Contains("password",     StringComparison.OrdinalIgnoreCase) ||
    key.Contains("secret",       StringComparison.OrdinalIgnoreCase) ||
    key.Contains("connectionstring", StringComparison.OrdinalIgnoreCase) ||
    key.Contains("apikey",       StringComparison.OrdinalIgnoreCase) ||
    key.Contains("token",        StringComparison.OrdinalIgnoreCase);

Mistake 2: Secrets in appsettings.json Committed to Git

Even if you delete the file in a later commit, the secret exists in git history forever. Anyone who has cloned the repository or can access the git remote has the secret.

If you accidentally commit a secret:

  1. Rotate the secret immediately — assume it is compromised
  2. Use git filter-repo or BFG Repo Cleaner to rewrite history (this rewrites all commit SHAs — coordinate with your team)
  3. Force-push to all remotes
  4. Verify the secret no longer appears with git log --all -p | grep "the-secret-value"

Prevention: add a pre-commit hook using git-secrets or trufflehog:

Bash
# Install git-secrets
brew install git-secrets   # macOS; use choco or scoop on Windows

# Configure patterns for common secret formats
git secrets --install
git secrets --register-aws
git secrets --add "password\s*=\s*['\"].+['\"]"

Mistake 3: Secrets in Dockerfile ENV Instructions

DOCKERFILE
# BAD: the secret bakes into every image layer and is visible in 'docker inspect'
FROM mcr.microsoft.com/dotnet/aspnet:8.0
ENV ConnectionStrings__Database="Server=prod;Password=secret123"

Anyone who can pull the image or run docker inspect sees the secret. Use Docker secrets or pass secrets at runtime via environment variables from a vault:

DOCKERFILE
# GOOD: no secrets in the image
FROM mcr.microsoft.com/dotnet/aspnet:8.0
WORKDIR /app
COPY --from=build /app/publish .
ENTRYPOINT ["dotnet", "YourApp.dll"]
# Secrets are injected at runtime by the orchestrator (AKS, App Service, ECS)

For Kubernetes, use External Secrets Operator or the Azure Key Vault CSI Driver to mount Key Vault secrets as Kubernetes secrets without storing them in git:

YAML
# externalsecret.yaml (External Secrets Operator)
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: yourapp-secrets
spec:
  secretStoreRef:
    name: azure-keyvault
    kind: ClusterSecretStore
  target:
    name: yourapp-secrets
  data:
    - secretKey: ConnectionStrings__Database
      remoteRef:
        key: ConnectionStrings--Database

Mistake 4: Sharing Secrets via Slack or Email

A secret shared in Slack is now in Slack's servers, search history, and potentially their AI training data. Use a proper secrets-sharing mechanism:

  • For onboarding: provision managed identity access, not actual secret values
  • For contractors: create a scoped Azure AD account with limited Key Vault access
  • For emergencies: use Azure Key Vault's temporary access grants with a 1-hour expiry

Complete Program.cs — The Right Pattern

C#
// Program.cs — complete secrets management setup

var builder = WebApplication.CreateBuilder(args);

// ── Configuration pipeline ──────────────────────────────────────────────
// appsettings.json      → non-secret configuration (URLs, feature flags, etc.)
// appsettings.{env}.json → environment-specific non-secrets
// User secrets          → developer secrets (Development only, auto-added by builder)
// Key Vault             → all production secrets (non-Development only)

if (!builder.Environment.IsDevelopment())
{
    var kvUri = builder.Configuration["KeyVault:Uri"]
        ?? throw new InvalidOperationException(
            "KeyVault:Uri must be set in appsettings.Production.json or environment variables. " +
            "This is the Key Vault URI (not a secret): https://yourapp-kv-prod.vault.azure.net/");

    builder.Configuration.AddAzureKeyVault(
        new Uri(kvUri),
        new DefaultAzureCredential(),
        new AzureKeyVaultConfigurationOptions
        {
            // Reload secrets every 30 minutes without a restart
            ReloadInterval = TimeSpan.FromMinutes(30)
        });
}

// ── Options binding (reads from IConfiguration, which may be Key Vault) ─
builder.Services.Configure<JwtOptions>(builder.Configuration.GetSection("Jwt"));
builder.Services.Configure<PaymentGatewayOptions>(
    builder.Configuration.GetSection("ExternalApi:PaymentGateway"));

// ── Secret manager (DI abstraction) ─────────────────────────────────────
if (builder.Environment.IsDevelopment())
{
    builder.Services.AddSingleton<ISecretManager, ConfigurationSecretManager>();
}
else
{
    var kvUri = builder.Configuration["KeyVault:Uri"]!;
    builder.Services.AddSingleton(new SecretClient(new Uri(kvUri), new DefaultAzureCredential()));
    builder.Services.AddSingleton<ISecretManager, KeyVaultSecretManager>();
    builder.Services.AddHostedService<SecretRotationService>();
}

var app = builder.Build();

// ── Startup validation: fail fast if secrets are missing ─────────────────
var jwtOptions = app.Services.GetRequiredService<IOptions<JwtOptions>>().Value;
if (string.IsNullOrEmpty(jwtOptions.Secret))
    throw new InvalidOperationException(
        "Jwt:Secret is not configured. " +
        "Development: dotnet user-secrets set \"Jwt:Secret\" \"<value>\". " +
        "Production: ensure Key Vault secret 'Jwt--Secret' exists and managed identity has access.");

app.Run();

Summary

| Stage | Tool | Never Use | |---|---|---| | Local dev | dotnet user-secrets | Plaintext in appsettings.json | | CI pipelines | Vault-injected env vars | Pipeline variables stored in UI | | Staging / Prod | Azure Key Vault + Managed Identity | Environment variables in Dockerfile | | Code | IOptions<T> / ISecretManager | Environment.GetEnvironmentVariable inline | | Logging | Filtered, never log secret keys | logger.LogInformation(configuration["Jwt:Secret"]) |

The investment in proper secrets management is a one-time setup cost that prevents a potentially catastrophic, irreversible breach. The Azure Key Vault free tier covers most startup workloads at no cost; the only real cost is the initial setup time.