Secrets Management
Key Points
- Never commit secrets to source control. Once in git history, treat as compromised.
- Local dev:
dotnet user-secrets(per-project, stored in user profile, not committed). - Cloud: Azure Key Vault + Managed Identity is the golden path on Azure. AWS Secrets Manager / Parameter Store on AWS. HashiCorp Vault for multi-cloud / on-prem.
- Configuration provider chain in ASP.NET: appsettings.json → appsettings.{env}.json → user secrets (dev) → env vars → command line. Add Key Vault as a provider with one line.
- Rotate secrets routinely. Apps should reload on rotation (Key Vault reload via
IOptionsMonitoror sentinel pattern). - Secret scanning in CI (GitHub secret scanning, gitleaks). Block PRs with detected secrets.
Concepts (deep dive)
The configuration provider chain
// Implicit chain in WebApplicationBuilder:
appsettings.json
appsettings.{Environment}.json
User Secrets (Development only)
Environment variables
Command-line arguments
Later providers override earlier. Add Key Vault near the end:
if (!builder.Environment.IsDevelopment())
{
var vaultUri = new Uri(builder.Configuration["KeyVault:Uri"]!);
builder.Configuration.AddAzureKeyVault(vaultUri, new DefaultAzureCredential());
}
User secrets (local dev)
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:Default" "Server=...;Trusted_Connection=true"
dotnet user-secrets list
Stored in %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json (Windows) or ~/.microsoft/usersecrets/<id>/secrets.json (Linux/Mac). Per-project ID in .csproj:
Not encrypted on disk — relies on user-profile permissions. Dev only.
Azure Key Vault + Managed Identity
The pattern:
No connection strings, no client secrets. The runtime presents an identity token; Key Vault verifies via Entra ID.
builder.Configuration.AddAzureKeyVault(
new Uri("https://my-vault.vault.azure.net/"),
new DefaultAzureCredential(),
new AzureKeyVaultConfigurationOptions { ReloadInterval = TimeSpan.FromMinutes(15) });
// Use as normal config:
var connStr = builder.Configuration["ConnectionStrings--Default"];
Key Vault keys flatten dashes into colons: ConnectionStrings--Default becomes ConnectionStrings:Default.
RBAC scoping: grant only Get/List on secrets; never Set (rotation is a privileged op).
Reload on rotation
AzureKeyVaultConfigurationOptions.ReloadInterval polls. New value flows to IOptionsMonitor<T> consumers. IOptions<T> snapshots at startup — won't pick up changes.
// ❌ Stale on rotation
public class C(IOptions<DbOptions> opts) { _connStr = opts.Value.ConnectionString; }
// ✅ Re-read on change
public class C(IOptionsMonitor<DbOptions> opts) { _connStr = opts.CurrentValue.ConnectionString; }
For long-lived connections (DB pools, gRPC channels), reload requires explicit re-creation. Plan for it.
Sentinel pattern
For Redis or App Configuration: the app subscribes to a "version" key; on change, it reloads. Less polling.
Environment variables
Common in containers. Convenient but can leak via printenv, process listings, crash dumps.
appsettings.{env}.json
Per-environment overrides — typically non-secret values (URLs, feature flags). Secrets go in Key Vault / env vars, NOT here.
Container secrets
- Kubernetes Secrets — base64, not encrypted at rest by default. Enable etcd encryption + RBAC + use Sealed Secrets or External Secrets Operator (pulls from Key Vault).
- Docker secrets (Swarm) — files mounted into
/run/secrets/. Ephemeral. - Aspire has built-in support for env-var-based secrets in dev.
Secret rotation
Rotation needs: 1. Vault stores new version. 2. App fetches new version on next reload. 3. Downstream service accepts both versions during the cutover.
For DB connection strings, rotation typically means a second SQL login is added, app picks it up, old login is removed.
Where to store the Key Vault URI
Yes — even the URI itself is "config", but it's not a secret. Put in appsettings.json or env var.
Connection strings with managed identity
Better than secrets — no rotation:
The driver picks up the managed identity. Same pattern for SQL, Cosmos, Storage, Service Bus.
Secret scanning
In CI:
GitHub also has built-in secret scanning that alerts on commit. Treat any leaked secret as compromised — rotate immediately, even if you "removed it from history".
IConfiguration access patterns
// Direct access — flat
var x = config["Foo:Bar"];
// Bind to typed Options — preferred
public class FooOptions { public string Bar { get; set; } = ""; }
builder.Services.Configure<FooOptions>(config.GetSection("Foo"));
class Consumer(IOptions<FooOptions> opts) { /* opts.Value.Bar */ }
Typed options give IntelliSense, validation (ValidateDataAnnotations, ValidateOnStart), and reload semantics.
Validation
builder.Services
.AddOptions<FooOptions>()
.Bind(builder.Configuration.GetSection("Foo"))
.ValidateDataAnnotations()
.Validate(o => !string.IsNullOrWhiteSpace(o.Bar), "Bar required")
.ValidateOnStart();
ValidateOnStart fails the app at boot if config is invalid — better than discovering at request 1.
Preventing accidental commits
Add appsettings.Development.json to .gitignore. Use a .env.example instead of .env. Pre-commit hook with gitleaks.
Code: correct vs wrong
❌ Wrong: hardcoded password
✅ Correct: managed identity
❌ Wrong: secrets in appsettings.json
✅ Correct: in user secrets / Key Vault
dotnet user-secrets set ConnectionStrings:Default "Server=..." # dev
# prod: stored in Key Vault, fetched at startup
❌ Wrong: IOptions<T> for rotated secrets
✅ Correct: IOptionsMonitor<T>
Design patterns for this topic
Pattern 1 — "Managed identity everywhere"
- Intent: eliminate secret management; let Azure handle.
Pattern 2 — "Key Vault as config provider"
- Intent: secrets indistinguishable from regular config in code.
Pattern 3 — "ValidateOnStart"
- Intent: fail fast on missing/invalid config.
Pattern 4 — "Dual-key rotation window"
- Intent: zero-downtime secret rotation.
Pattern 5 — "User secrets locally; Vault in cloud"
- Intent: consistent code; environment differs only in provider chain.
Pros & cons / trade-offs
| Provider | Pros | Cons |
|---|---|---|
appsettings.json | Simple | Bad for secrets |
| User secrets | Dev-friendly | Not for prod |
| Env vars | Container-friendly | Leak via process listing |
| Key Vault | Audited; rotation | Network dep at startup |
| Managed Identity | No secret to manage | Azure-only |
When to use / when to avoid
- Always use managed identity over connection strings if the platform supports it.
- Use Key Vault for any secret you can't eliminate.
- Use user secrets for local dev only.
- Never commit secrets to source — even in
.envfiles. - Avoid env vars for highly sensitive secrets — they're visible to every process and crash dump.
Interview Q&A
Q1. Where do user secrets live? %APPDATA%\Microsoft\UserSecrets\<id>\secrets.json (Windows). Per-project ID in .csproj. Dev only.
Q2. Why prefer managed identity? No secret to rotate, leak, or store. The platform issues short-lived tokens.
Q3. How does Key Vault flatten keys? Dashes (--) become colons in config — ConnectionStrings--Default → ConnectionStrings:Default.
Q4. IOptions vs IOptionsMonitor vs IOptionsSnapshot? IOptions: singleton, snapshot at start. IOptionsSnapshot: per-request scope, re-read each request. IOptionsMonitor: singleton, fires on change — only one that picks up rotation in long-lived services.
Q5. What's ValidateOnStart? Runs validation at app start. Misconfiguration crashes boot, not request 1.
Q6. Senior approach to rotation? Dual-key window: vault stores new alongside old; downstreams accept both during cutover; old key removed afterwards.
Q7. Why is BinaryFormatter-style insecure deserialization not a "secrets" concern? Different category — it's data integrity (OWASP A08), but related — both about what crosses trust boundaries.
Q8. Are env vars secret? Lower-grade secret store. Avoid for highly sensitive; OK for less-critical or as fallback to Vault.
Q9. Audit secret access? Key Vault has built-in access logs (Diagnostic Settings → Log Analytics). Investigate anomalies (off-hours access, unfamiliar IPs).
Q10. What's a sentinel pattern? A version key: when it changes, app reloads config. Avoids polling all keys.
Gotchas / common mistakes
- ⚠️ Committing
.envorappsettings.Development.json— git history leak. - ⚠️
IOptions<T>for rotating secrets — never picks up change. - ⚠️ No
ValidateOnStart— silent misconfig until first request. - ⚠️ Wide RBAC on Vault (
Setpermission for the app). - ⚠️ Single Key Vault for all environments — staging compromise = prod compromise.