Managing Secrets in .NET 8: User Secrets, Azure Key Vault Provider & Environment-Based Secret Rotation

The Exposure Surface You're Not Thinking About

Every team knows not to commit secrets to source control. The problem is that appsettings.json feels like configuration, not source code — so secrets end up there anyway, committed with a comment that says "TODO: move this before go-live," which never happens. But the Git repository is only one exposure surface. Build artefacts are another. Docker image layers are another. The CI/CD pipeline log that prints environment variables during a failed deployment step is another. The Slack thread where someone pasted the appsettings file to debug a production issue is another. Every time a secret touches a file that gets committed, copied, or forwarded, the exposure surface grows by one.

The correct architecture is not about discipline — it is about making the wrong approach structurally impossible. .NET 8's IConfiguration provider model makes this achievable: each environment gets its own secrets source, secrets never touch the files that get committed or copied, and the application code is completely unaware of where its configuration values came from. The three tools that cover every environment in the stack are User Secrets for local development, Azure Key Vault as an IConfiguration provider for production, and IOptionsMonitor<T> for absorbing secret rotations without a process restart.

This article covers all three — with the exact code to wire each one into a .NET 8 application, the DefaultAzureCredential pattern that eliminates client secrets entirely, the AppPrefixKeyVaultSecretManager that prevents cross-application secret exposure in shared vaults, and the ReloadInterval plus IOptionsMonitor<T> combination that makes database credential rotation a configuration event rather than a deployment event.

User Secrets: Per-Developer Local Configuration That Cannot Be Committed

User Secrets store development credentials in a JSON file under your OS user profile directory — outside the project directory entirely — so no amount of careless git add . can accidentally commit them. They are automatically loaded by WebApplication.CreateBuilder when ASPNETCORE_ENVIRONMENT is Development and silently skipped in all other environments. They are not encrypted. They are protected by file system permissions and physical separation from the project. That is the correct trade-off for a local development tool: encryption at rest adds friction with no meaningful security benefit for a file only the logged-in developer can access.

Terminal + Program.cs — User Secrets Initialisation & Auto-Loading
// ── Step 1: Initialise User Secrets for the project ───────────────────────
// Adds a  GUID to the .csproj file.
// This GUID is the only secrets-related artefact that belongs in source control.
// dotnet user-secrets init

// ── Step 2: Set individual secrets ────────────────────────────────────────
// Stored in a JSON file OUTSIDE the project directory — never in the repo.
// Each key uses the same colon-separated hierarchy as appsettings.json.
// dotnet user-secrets set "Database:ConnectionString" "Server=localhost;Database=MyAppDev;..."
// dotnet user-secrets set "Stripe:SecretKey"          "sk_test_abc123..."
// dotnet user-secrets set "SendGrid:ApiKey"           "SG.xxx..."
// dotnet user-secrets set "Jwt:SigningKey"             "dev-only-local-signing-key-256bit"

// ── Step 3: Manage secrets ────────────────────────────────────────────────
// dotnet user-secrets list                    — list all keys (not values)
// dotnet user-secrets remove "Stripe:SecretKey"
// dotnet user-secrets clear                   — remove all secrets for this project

// ── Storage location (never inside your project folder) ───────────────────
// Windows: %APPDATA%\Microsoft\UserSecrets\{UserSecretsId}\secrets.json
// Linux:   ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json
// macOS:   ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json

// ── Program.cs: zero registration required ────────────────────────────────
// WebApplication.CreateBuilder automatically adds the User Secrets provider
// when Environment is Development. No AddUserSecrets() call needed.
var builder = WebApplication.CreateBuilder(args);

// Read directly from IConfiguration — same key path as appsettings.json
var connStr = builder.Configuration["Database:ConnectionString"];

// Better: use typed options so the key path is defined once in one place
builder.Services
    .AddOptions()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();   // refuse to start if a required secret is missing

// ── Provider priority order (last registered wins) ────────────────────────
// 1. appsettings.json               — safe placeholder values ("not-set")
// 2. appsettings.Development.json   — dev-specific non-secret overrides
// 3. User Secrets (Development only) ← overrides both appsettings files
// 4. Environment variables           ← overrides User Secrets
// 5. Command-line arguments          ← overrides everything

// ── What belongs in appsettings.json ──────────────────────────────────────
// Non-secret configuration only: feature flags, timeouts, URLs, log levels.
// For secret-shaped keys, use a placeholder that makes misconfiguration obvious:
// "Database": { "ConnectionString": "REPLACE-WITH-USER-SECRET" }
// If the placeholder value reaches a running app, ValidateOnStart catches it.

// ── The one team workflow that eliminates secrets in the repo ─────────────
// 1. New developer clones repo
// 2. Runs: dotnet user-secrets init (if not already initialised)
// 3. Runs: dotnet user-secrets set "Database:ConnectionString" "their-local-value"
// 4. Application starts. No secrets file in the repo, ever.
// The UserSecretsId GUID in .csproj is the only coordination artefact needed.

The ValidateOnStart() call in the typed options registration is the enforcement mechanism that makes placeholder values in appsettings.json safe. Without it, a missing or placeholder secret is silent until the first request that touches the affected service — which might be a payment flow or an authentication handler that only triggers under specific conditions. With it, the application refuses to start, the deployment pipeline fails during the health check probe, and the missing secret surfaces in the CI/CD log rather than in a 2am production incident.

AddAzureKeyVault(): Key Vault as a Transparent IConfiguration Layer

The Azure Key Vault configuration provider loads secrets from Key Vault into IConfiguration at startup using the same layered provider model as every other configuration source. Your application code reads builder.Configuration["Stripe:SecretKey"] — it has no awareness of whether the value came from a local JSON file, an environment variable, or Key Vault. The only change is in the provider registration in Program.cs.

DefaultAzureCredential is the authentication mechanism that makes this pattern production-safe. In Azure — App Service, Azure Kubernetes Service, Azure Functions, Container Apps — it resolves to the Managed Identity assigned to your resource. In local development, it falls back to your Azure CLI login. Your application code never stores or rotates an Azure credential in order to access a credential store. The circular dependency that plagues client-secret-based Key Vault authentication is eliminated by design.

Program.cs — AddAzureKeyVault() With Prefix Manager & Naming Convention
using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

var builder = WebApplication.CreateBuilder(args);

// ── Add Key Vault provider — skip in Development (use User Secrets there) ─
// This keeps local development fast — no Key Vault round-trip on startup —
// and preserves the correct tool-for-environment separation.
if (!builder.Environment.IsDevelopment())
{
    var kvUri = builder.Configuration["KeyVault:Uri"]
        ?? throw new InvalidOperationException(
            "KeyVault:Uri must be set in appsettings.{Environment}.json. " +
            "This value is not a secret — it is the vault's HTTPS endpoint.");

    // DefaultAzureCredential resolution order:
    //   1. EnvironmentCredential    (AZURE_CLIENT_ID + AZURE_CLIENT_SECRET env vars)
    //   2. WorkloadIdentityCredential (AKS workload identity)
    //   3. ManagedIdentityCredential  (App Service, Azure Functions, Container Apps)
    //   4. AzureCliCredential         (local dev: az login)
    //   5. AzurePowerShellCredential
    //   6. InteractiveBrowserCredential (last resort)
    //
    // In production: ManagedIdentityCredential wins — zero credentials in code.
    // In local dev:  AzureCliCredential wins — run 'az login' once, done.
    var credential = new DefaultAzureCredential();

    // ── Basic registration: loads ALL secrets from the vault ──────────────
    // Suitable for a dedicated single-app vault.
    // builder.Configuration.AddAzureKeyVault(new Uri(kvUri), credential);

    // ── Recommended: use AppPrefixKeyVaultSecretManager ───────────────────
    // REQUIRED for shared vaults — prevents one app reading another app's secrets.
    // Also reduces startup time by filtering secrets at the source.
    builder.Configuration.AddAzureKeyVault(
        new Uri(kvUri),
        credential,
        new AppPrefixKeyVaultSecretManager("MyApp"));
}

// ── AppPrefixKeyVaultSecretManager: load only this app's secrets ──────────
// Key Vault secret names cannot contain colons — use double-dash as separator.
// This manager:
//   1. Filters: only loads secrets whose name starts with "MyApp--"
//   2. Strips:  removes the "MyApp--" prefix from the IConfiguration key
//   3. Converts: "--" → ":" so the key matches the appsettings.json hierarchy
public sealed class AppPrefixKeyVaultSecretManager(string prefix)
    : KeyVaultSecretManager
{
    private readonly string _prefix = prefix + "--";

    // Only load secrets whose name begins with "MyApp--"
    public override bool Load(SecretProperties secret) =>
        secret.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);

    // Strip prefix and convert double-dash to colon:
    // "MyApp--Database--ConnectionString" → "Database:ConnectionString"
    public override string GetKey(KeyVaultSecret secret) =>
        secret.Name[_prefix.Length..].Replace("--", ":");
}

// ── Secret naming convention in Key Vault ─────────────────────────────────
// Key Vault name                       → IConfiguration key
// MyApp--Database--ConnectionString    → Database:ConnectionString
// MyApp--Stripe--SecretKey             → Stripe:SecretKey
// MyApp--Jwt--SigningKey                → Jwt:SigningKey
// MyApp--SendGrid--ApiKey              → SendGrid:ApiKey
//
// This maps to the same typed options structure as appsettings.json:
// builder.Services.AddOptions()
//     .BindConfiguration("Stripe")        // reads Stripe:SecretKey from wherever it came from
//     .ValidateDataAnnotations()
//     .ValidateOnStart();

// ── Required Azure RBAC: assign to Managed Identity on the Key Vault ──────
// Role: "Key Vault Secrets User" (read secret values)
// Scope: the Key Vault resource (not individual secrets — manage that at vault level)
// Assignment via Azure CLI:
// az role assignment create \
//   --role "Key Vault Secrets User" \
//   --assignee {managed-identity-object-id} \
//   --scope /subscriptions/{sub}/resourceGroups/{rg}/providers/Microsoft.KeyVault/vaults/{vault}

The KeyVault:Uri value in appsettings.{Environment}.json is not a secret — it is the public HTTPS endpoint of your vault and safe to commit to source control. Committing it to environment-specific appsettings files means the correct vault is selected per environment automatically when the configuration system loads. Never hard-code the vault URI in Program.cs — that makes it impossible to use a different vault per environment without a code change.

SecretClient: On-Demand Access for Versioned & Programmatic Secret Retrieval

AddAzureKeyVault() is the right default for secrets that your application needs at startup and reads throughout its lifetime. It is not the right tool for every Key Vault interaction. When you need to retrieve a specific version of a secret by version ID, list the version history for audit purposes, create or update a secret programmatically from application code, or fetch a secret lazily on first use rather than eagerly at startup, you need the SecretClient directly — the Azure SDK client that AddAzureKeyVault() is built on top of.

Program.cs + Services/SecretAuditService.cs — SecretClient Direct Usage
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;

// ── Register SecretClient in the DI container ─────────────────────────────
// Use the same DefaultAzureCredential so local dev (Azure CLI) and
// production (Managed Identity) both work with the same registration.
var builder = WebApplication.CreateBuilder(args);

if (!builder.Environment.IsDevelopment())
{
    var kvUri    = builder.Configuration["KeyVault:Uri"]!;
    var credential = new DefaultAzureCredential();

    // Register SecretClient as a singleton — it is thread-safe and expensive to create.
    // Use it alongside AddAzureKeyVault() for different purposes:
    //   AddAzureKeyVault() → secrets loaded into IConfiguration at startup
    //   SecretClient       → on-demand retrieval, versioning, programmatic management
    builder.Services.AddSingleton(new SecretClient(new Uri(kvUri), credential));
}


// ── Service: versioned secret retrieval for audit and rotation logging ─────
public sealed class SecretAuditService(SecretClient secretClient)
{
    // ── Retrieve the CURRENT version of a secret ──────────────────────────
    // Returns the latest active version — same as AddAzureKeyVault() reads.
    public async Task GetCurrentSecretAsync(
        string secretName, CancellationToken ct = default)
    {
        try
        {
            var response = await secretClient.GetSecretAsync(secretName, cancellationToken: ct);
            return response.Value.Value;
        }
        catch (Azure.RequestFailedException ex) when (ex.Status == 404)
        {
            return null;   // secret does not exist — return null, don't throw
        }
    }

    // ── Retrieve a SPECIFIC version of a secret ───────────────────────────
    // Required for: post-rotation verification, incident analysis,
    //               "what was the DB password before the rotation at 14:32?"
    public async Task GetSecretVersionAsync(
        string secretName,
        string versionId,
        CancellationToken ct = default)
    {
        try
        {
            var response = await secretClient.GetSecretAsync(secretName, versionId, ct);
            return response.Value;
        }
        catch (Azure.RequestFailedException ex) when (ex.Status == 404)
        {
            return null;
        }
    }

    // ── List all versions of a secret ─────────────────────────────────────
    // Returns metadata only — no secret values.
    // Use for: audit logging, rotation history, compliance reporting.
    public async Task> ListSecretVersionsAsync(
        string secretName, CancellationToken ct = default)
    {
        var versions = new List();

        await foreach (var version in
            secretClient.GetPropertiesOfSecretVersionsAsync(secretName, ct))
        {
            versions.Add(version);
        }

        // Most recent version first
        return versions
            .OrderByDescending(v => v.CreatedOn)
            .ToList()
            .AsReadOnly();
    }

    // ── Programmatic secret creation (deployment pipelines, rotation scripts) ─
    // Use when: your application itself participates in the rotation process,
    //           for example rotating its own database password and storing the
    //           new value back to Key Vault after updating the database user.
    public async Task SetSecretAsync(
        string secretName,
        string secretValue,
        DateTimeOffset? expiresOn = null,
        CancellationToken ct = default)
    {
        var secret = new KeyVaultSecret(secretName, secretValue);

        if (expiresOn.HasValue)
            secret.Properties.ExpiresOn = expiresOn;

        var response = await secretClient.SetSecretAsync(secret, ct);

        // Return the version ID of the newly created secret version.
        // Store this in your audit log alongside the rotation timestamp.
        return response.Value.Properties.Version!;
    }
}

// ── When to use SecretClient vs AddAzureKeyVault() ────────────────────────
//
// AddAzureKeyVault() (IConfiguration provider):
//   ✓ Secrets needed at startup and throughout application lifetime
//   ✓ Secrets consumed via IOptions binding — no Azure SDK in business logic
//   ✓ Secrets that benefit from ReloadInterval live reload on rotation
//   ✗ Cannot retrieve specific versions by ID
//   ✗ Cannot list version history
//   ✗ Cannot create or update secrets from application code
//
// SecretClient (direct SDK usage):
//   ✓ Versioned retrieval (specific version ID)
//   ✓ Listing version history for audit
//   ✓ Programmatic secret creation or rotation
//   ✓ Lazy on-demand retrieval (not needed at startup)
//   ✗ More verbose — requires Azure SDK dependency in the calling service
//   ✗ Does not integrate with IConfiguration / IOptions automatically

The version ID returned by SetSecretAsync is the key artefact in an automated rotation audit trail. Every rotation event should record: the secret name, the old version ID, the new version ID, the timestamp, and the identity that performed the rotation. Key Vault's built-in audit log captures the access events; your application's rotation log captures the business context. Together they give you the complete picture required by SOC 2 and PCI-DSS audit frameworks without any custom audit database schema.

Secret Rotation Without Restarts: ReloadInterval & IOptionsMonitor<T>

Environment variable-based secrets require a process restart to pick up a rotated value — the variable is baked in at startup and cannot change at runtime. The Azure Key Vault configuration provider solves this with ReloadInterval: a background timer re-reads all secrets from Key Vault on the configured interval, updates the IConfiguration values in memory, and notifies any IOptionsMonitor<T> subscribers through the OnChange callback. Your application picks up a rotated database password, API key, or signing key without a restart, without a redeployment, and without dropping a single in-flight request.

The critical implementation detail is which IOptions variant your services consume. IOptions<T> is a startup snapshot — it never reflects runtime changes regardless of ReloadInterval. IOptionsSnapshot<T> re-reads per request scope but requires a scoped service. IOptionsMonitor<T> is live — it reflects the current value at all times and fires OnChange when the underlying configuration changes. Singleton services that need to react to rotated secrets must use IOptionsMonitor<T>.

Program.cs + Services/DatabaseService.cs — ReloadInterval & IOptionsMonitor Rotation
// ── Program.cs: configure ReloadInterval on the Key Vault provider ─────────
if (!builder.Environment.IsDevelopment())
{
    var kvUri    = builder.Configuration["KeyVault:Uri"]!;
    var credential = new DefaultAzureCredential();

    builder.Configuration.AddAzureKeyVault(
        new Uri(kvUri),
        credential,
        new AzureKeyVaultConfigurationOptions
        {
            Manager      = new AppPrefixKeyVaultSecretManager("MyApp"),

            // Re-read ALL secrets from Key Vault every 5 minutes.
            // The background timer fires even while the app is serving traffic.
            // IOptionsMonitor subscribers are notified via OnChange callback.
            // No restart required. No in-flight request interruption.
            ReloadInterval = TimeSpan.FromMinutes(5)

            // Sizing the interval:
            //   Too short (< 1 min) → excessive Key Vault API calls, potential throttling
            //   Too long  (> 30 min)→ long window between rotation and pickup
            //   5 minutes is the standard production recommendation for most workloads.
            //   For high-security environments, combine with Azure Event Grid rotation
            //   events to trigger an immediate reload rather than relying on polling.
        });
}

// ── Options class: the typed representation of a rotatable secret group ───
public sealed class DatabaseOptions
{
    public const string SectionName = "Database";

    [Required]
    public string ConnectionString { get; init; } = "";

    [Range(1, 200)]
    public int MaxPoolSize { get; init; } = 100;
}

// ── Program.cs: register with IOptionsMonitor support ────────────────────
builder.Services
    .AddOptions()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Register the database service as singleton — it manages its own connection pool.
builder.Services.AddSingleton();


// ── DatabaseService: reacts to rotated credentials via IOptionsMonitor ─
public sealed class DatabaseService : IDisposable
{
    private readonly IDisposable?                          _optionsChangeToken;
    private readonly ILogger              _logger;
    private          SqlConnection?                        _connection;
    private readonly Lock                                  _lock = new();

    public DatabaseService(
        IOptionsMonitor optionsMonitor,
        ILogger         logger)
    {
        _logger = logger;

        // Initialise connection with current options
        InitialiseConnection(optionsMonitor.CurrentValue);

        // Register OnChange callback — fires every time IConfiguration updates
        // which happens on each ReloadInterval tick that detects changed values.
        _optionsChangeToken = optionsMonitor.OnChange(newOptions =>
        {
            _logger.LogInformation(
                "Database credentials changed. Recycling connection pool.");

            // Thread-safe connection pool recycle
            lock (_lock)
            {
                RecycleConnection(newOptions);
            }
        });
    }

    private void InitialiseConnection(DatabaseOptions options)
    {
        lock (_lock)
        {
            _connection?.Dispose();
            _connection = new SqlConnection(options.ConnectionString);
            // In production: use a connection pool manager rather than a single
            // SqlConnection instance. The pattern is the same — dispose the old
            // pool and create a new one with the updated connection string.
        }
    }

    private void RecycleConnection(DatabaseOptions options)
    {
        try
        {
            // Close old connection/pool gracefully — in-flight queries complete
            _connection?.Dispose();

            // Open new connection with rotated credentials
            _connection = new SqlConnection(options.ConnectionString);

            _logger.LogInformation("Connection pool recycled successfully after credential rotation.");
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "Failed to recycle connection pool after credential rotation. " +
                "Service will continue with existing connection until next retry.");
            // Do NOT throw here — a failed recycle should not crash the service.
            // The old connection may still work if the rotation window allows it.
            // The next OnChange trigger will attempt another recycle.
        }
    }

    public async Task> QueryAsync(
        string sql, CancellationToken ct = default)
    {
        // Acquire connection safely — OnChange may be recycling concurrently
        SqlConnection connection;
        lock (_lock) { connection = _connection!; }

        // Execute query against the current connection
        await using var cmd = connection.CreateCommand();
        cmd.CommandText = sql;
        // ... execute and return results
        return [];
    }

    public void Dispose()
    {
        _optionsChangeToken?.Dispose();   // unregister OnChange — prevents memory leak
        _connection?.Dispose();
    }
}

// ── IOptions vs IOptionsSnapshot vs IOptionsMonitor summary ──────
//
// IOptions         — startup snapshot. Never updates at runtime.
//                       Use for: settings that must not change mid-lifetime.
//
// IOptionsSnapshot — re-reads per request scope. Scoped services only.
//                       Use for: per-request settings in scoped services (DbContext).
//
// IOptionsMonitor  — live value + OnChange callback. Works in singletons.
//                       Use for: singleton services that must react to rotated secrets.
//                       Always dispose the token returned by OnChange to prevent leaks.

The Dispose call on the token returned by IOptionsMonitor<T>.OnChange is not optional. The options monitor holds a reference to the callback for the lifetime of the registration. A singleton service that registers an OnChange callback without disposing the token creates a memory leak — the callback is never unregistered, and if the service is replaced (for example in a hot-reload scenario during development), the old callback continues firing against a disposed object. Always store the token, always dispose it in Dispose().

The Environment Decision Matrix: Which Tool, Which Context

Three tools, six environments, one rule: the secret never touches a file that leaves the machine it was meant for. The matrix below makes the selection mechanical — each environment has exactly one correct answer, and the answers are additive rather than exclusive. A production Kubernetes cluster running on Azure uses Key Vault with Managed Identity; if you also need to pass non-rotating infrastructure-level secrets at pod startup, you complement Key Vault with Kubernetes Secrets mounted as environment variables. The tools stack rather than compete.

SecretsDecisionMatrix.cs — Environment-to-Tool Mapping
// ════════════════════════════════════════════════════════════════════════════
// SECRETS MANAGEMENT DECISION MATRIX — .NET 8
// ════════════════════════════════════════════════════════════════════════════

// ── LOCAL DEVELOPER MACHINE ───────────────────────────────────────────────
// Tool:   User Secrets
// Why:    Outside project directory — structurally cannot be committed.
//         Per-developer — no shared credentials, no collision between teammates.
//         Auto-loaded in Development — zero Program.cs configuration required.
//         Not encrypted — correct trade-off for a file only the dev can access.
// Setup:  dotnet user-secrets set "Section:Key" "value"
// Avoid:  appsettings.local.json in .gitignore — still a repo artefact.
//         .env files with real credentials — leak risk on git add.

// ── CI/CD PIPELINE (GitHub Actions, Azure DevOps, Jenkins) ───────────────
// Tool:   Pipeline secret store → environment variables
// Why:    GitHub Actions Secrets / Azure DevOps Variable Groups are encrypted
//         at rest, masked in logs, and scoped per repository and environment.
//         .NET reads them automatically via the environment variable provider.
//         Double-underscore maps to colon: STRIPE__SECRETKEY → Stripe:SecretKey
// Setup:  Set in pipeline UI or YAML secrets block. Inject as env vars at run time.
// Avoid:  Hard-coding in pipeline YAML — visible in repo and PR diffs.

// ── AZURE APP SERVICE / AZURE FUNCTIONS / CONTAINER APPS ─────────────────
// Tool:   Azure Key Vault with Managed Identity (always)
// Why:    Key Vault references in App Service Configuration auto-refresh on
//         secret rotation — no redeployment required.
//         Managed Identity: no credential stored anywhere in your application.
//         Full audit log of every read, every version, every access identity.
// Setup:
//   1. Enable system-assigned Managed Identity on the App Service
//   2. Assign "Key Vault Secrets User" role to the identity on the vault
//   3. Use Key Vault references in App Service Configuration:
//      @Microsoft.KeyVault(SecretUri=https://myvault.vault.azure.net/secrets/MyApp--Stripe--SecretKey/)
//   OR use AddAzureKeyVault() in Program.cs with DefaultAzureCredential
// Avoid:  Connection strings in App Service Configuration as plain values —
//         they appear in deployment exports and management API responses.

// ── KUBERNETES (AKS or self-managed) ─────────────────────────────────────
// Tool A: Azure Key Vault Provider for Secrets Store CSI Driver
//         Best for: secrets that rotate — driver re-syncs on interval
//         Mounts Key Vault secrets as files or environment variables in the pod
//         Uses Workload Identity (the AKS-native Managed Identity equivalent)
//
// Tool B: Kubernetes Secrets → environment variables
//         Best for: stable non-rotating secrets, non-Azure clusters
//         kubectl create secret generic api-secrets \
//           --from-literal=Database__ConnectionString="Server=prod-db;..."
//         Reference in pod spec:
//           env:
//             - name: Database__ConnectionString
//               valueFrom:
//                 secretKeyRef:
//                   name: api-secrets
//                   key: Database__ConnectionString
//
// Avoid:  Plain values in deployment.yaml — committed to source control.
//         ConfigMaps for secrets — ConfigMaps are not encrypted at rest.

// ── NON-AZURE CLOUD (AWS, GCP, on-premises) ──────────────────────────────
// Tool:   AWS Secrets Manager / GCP Secret Manager / HashiCorp Vault
//         All implement the same IConfiguration provider pattern.
//         AWS:  Kralizec.Extensions.AWS.SecretsManager NuGet
//         GCP:  Google.Cloud.SecretManager.Client NuGet
//         Vault: VaultSharp or community providers
//         The DefaultAzureCredential equivalent for AWS is DefaultAWSCredential.
//         The pattern is identical — only the provider and credential chain differ.

// ── THE ONE UNIVERSAL RULE ────────────────────────────────────────────────
// appsettings.json:  non-secret config + safe placeholder values only.
//   "Stripe": { "SecretKey": "REPLACE-WITH-SECRET" }
// The placeholder makes misconfiguration obvious and ValidateOnStart() catchable.
// Any real secret value that touches this file is already exposed —
// Git history, build artefacts, and Docker layers are not purgeable in practice.

// ── COMPLIANCE REQUIREMENTS ───────────────────────────────────────────────
// SOC 2 / PCI-DSS / ISO 27001 mandate:
//   ✓ Secrets stored in a dedicated secrets manager (Key Vault or equivalent)
//   ✓ Audit log of every secret access with caller identity and timestamp
//   ✓ Secret versioning — previous value retrievable for incident analysis
//   ✓ Access revocable per identity without rotating the secret itself
//   ✓ Rotation policy enforced — Key Vault supports automatic rotation for
//     Azure SQL, Storage Account keys, and Event Hubs keys natively.
// All four requirements are met by Key Vault + Managed Identity.
// None of them are met by environment variables or appsettings.json.

The compliance requirements block at the bottom of the matrix is not aspirational — for any application handling payment data, personal health information, or financial records, these controls are contractual obligations. Key Vault with Managed Identity satisfies all four simultaneously without any custom audit infrastructure. The combination of the access audit log (who read which secret version at what time), RBAC-based access revocation (remove a service principal's access without touching the secret), and native rotation policies for supported Azure services makes it the only correct answer for regulated workloads — not a preferred option, the required one.

What Developers Want to Know

Are User Secrets safe for team development — can I share them across developers?

User Secrets are explicitly per-developer by design. Each developer runs dotnet user-secrets set once with their own local credentials — a personal database password, their own Stripe test key, their own SendGrid API key. There is no shared secrets file and no way for one developer's secrets to leak to another developer's machine or to source control. If your team needs shared credentials for a shared staging environment, that is an environment variable or Key Vault scenario depending on rotation and audit requirements. User Secrets solve the local development problem specifically — they are not a team secret-sharing mechanism.

What is DefaultAzureCredential and why should I use it instead of a client secret?

DefaultAzureCredential is a credential chain from the Azure.Identity package that tries multiple authentication mechanisms in order — Managed Identity in Azure, Azure CLI login in local development, Visual Studio credential, environment variables — and uses the first one that succeeds. In production it resolves to Managed Identity: no client secret ever exists in your application. In local development it falls back to your Azure CLI login: run az login once and the credential resolves automatically. A client secret requires you to store and rotate a credential in order to access a credential store — a circular dependency that Managed Identity eliminates entirely.

When should I use SecretClient directly instead of AddAzureKeyVault()?

Use AddAzureKeyVault() as the default — it loads secrets into IConfiguration at startup so they are available everywhere via typed IOptions<T> without any Azure SDK dependency in your business logic. Use SecretClient directly when you need capabilities the configuration provider does not expose: retrieving a specific secret version by version ID, listing version history for audit purposes, creating or updating secrets programmatically from application code, or fetching a secret lazily on first use rather than eagerly at startup. SecretClient is the lower-level Azure SDK client; AddAzureKeyVault() is the IConfiguration integration layer built on top of it.

How do I name secrets in Azure Key Vault to match my appsettings.json structure?

Azure Key Vault secret names cannot contain colons — replace them with double dashes. A secret named MyApp--Database--ConnectionString in Key Vault is loaded into IConfiguration as Database:ConnectionString, mapping to the ConnectionString property of a DatabaseOptions class bound to the Database section — exactly as if it came from appsettings.json. The default KeyVaultSecretManager handles this double-dash to colon conversion automatically. The AppPrefixKeyVaultSecretManager pattern additionally filters by application prefix so each app only loads its own secrets from a shared vault.

Does secret rotation via ReloadInterval restart my application?

No. ReloadInterval causes the Key Vault configuration provider to re-read all secrets on a background timer without restarting the process. Services that consume configuration via IOptionsMonitor<T> are notified through the OnChange callback and can react — typically by recycling a connection pool or reinitialising an SDK client. Services using IOptions<T> receive a startup snapshot and are never notified of changes. IOptionsSnapshot<T> re-reads per request scope but requires a scoped service. Use IOptionsMonitor<T> for any singleton service that must react to rotated secrets at runtime, and always dispose the token returned by OnChange to prevent memory leaks.

Back to Articles