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.
// ── 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.
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.
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: 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.
// ════════════════════════════════════════════════════════════════════════════
// 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.