Skip to content

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 IOptionsMonitor or 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:

<PropertyGroup>
  <UserSecretsId>4d8c4b7e-...</UserSecretsId>
</PropertyGroup>

Not encrypted on disk — relies on user-profile permissions. Dev only.

Azure Key Vault + Managed Identity

The pattern:

App (managed identity) → Key Vault (RBAC: get,list secrets) → secrets in HSM

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

export ConnectionStrings__Default="Server=..."   # double underscore = colon nesting

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

Old secret → both old and new accepted (dual-key window) → new only

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:

Server=tcp:my.database.windows.net,1433;Database=app;Authentication=Active Directory Default;

The driver picks up the managed identity. Same pattern for SQL, Cosmos, Storage, Service Bus.

Secret scanning

In CI:

- uses: gitleaks/gitleaks-action@v2
- uses: actions/checkout@v4
  with: { fetch-depth: 0 }

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

var connStr = "Server=db;User=sa;Password=hunter2;";

✅ Correct: managed identity

Server=tcp:db,1433;Database=app;Authentication=Active Directory Default;

❌ Wrong: secrets in appsettings.json

{ "ConnectionStrings": { "Default": "Server=...;Password=..." } }

✅ 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

public class Worker(IOptions<DbOptions> o) { _conn = o.Value.ConnectionString; }

✅ Correct: IOptionsMonitor<T>

public class Worker(IOptionsMonitor<DbOptions> o) { _conn = o.CurrentValue.ConnectionString; }

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 .env files.
  • 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--DefaultConnectionStrings: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 .env or appsettings.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 (Set permission for the app).
  • ⚠️ Single Key Vault for all environments — staging compromise = prod compromise.

Further reading