Configuration & Secrets Management
A database password just got committed to Git
Use Case: Configuration & Secrets Management
The everyday problem: Your app needs settings — connection strings, API keys, feature flags, timeouts — and they differ across environments (a different database in dev, staging, production) and include secrets that must never appear in source control. The classic disasters: a connection string with a password hardcoded in appsettings.json and committed to Git (now in history forever), an API key pasted into code, production settings accidentally shipped to a test environment, or a secret leaked in a log. The challenge is supplying the right configuration to each environment, keeping secrets out of source control and out of reach, and changing settings without redeploying — all while keeping local development simple.
Five approaches, layering from the built-in configuration system to dedicated secret managers. The .NET configuration system is designed around layering — multiple sources combined with later ones overriding earlier — which is the key to getting this right.
Way 1: Layered Configuration Sources (the foundation)
Best used for: The base configuration mechanism for every .NET app — combining JSON files, environment variables, and command-line args so each environment gets the right values through override layering.
The concept: .NET's configuration system reads from multiple sources in order, with later sources overriding earlier ones. The default order: appsettings.json (base) → appsettings.{Environment}.json (per-environment) → environment variables → command-line. So shared defaults live in the base file, environment-specific overrides in the environment file, and secrets/deployment-specific values come from environment variables — without changing code.
// appsettings.json — shared defaults, NO secrets, safe to commit
{
"Logging": { "LogLevel": { "Default": "Information" } },
"FeatureFlags": { "NewCheckout": false },
"Timeouts": { "ApiSeconds": 30 }
}
// appsettings.Production.json — environment overrides, still NO secrets
{
"Logging": { "LogLevel": { "Default": "Warning" } },
"FeatureFlags": { "NewCheckout": true }
}
// Environment variables override files — secrets come from HERE, not from committed files.
// ConnectionStrings__Default="Server=...;Password=..." (double underscore = nesting)
// FeatureFlags__NewCheckout=true
// The host builds this layered configuration automatically:
var builder = WebApplication.CreateBuilder(args);
// Order (later wins): appsettings.json → appsettings.{Env}.json → env vars → cmd line
// Read values
var timeout = builder.Configuration.GetValue<int>("Timeouts:ApiSeconds");
var connStr = builder.Configuration.GetConnectionString("Default");
Why it works: Layering separates concerns: shared defaults in the committed base file, per-environment differences in environment files, and secrets/per-deployment values injected via environment variables that never touch source control. The same code reads the merged result, so deploying to a different environment changes behaviour purely through configuration, not code changes.
Benchmark note: This layered model is the foundation everything else builds on, and getting the discipline right matters more than any tool: never put secrets in any committed file — not appsettings.json, not appsettings.Production.json (it is still in the repo). Those files are for non-secret defaults and toggles. Secrets enter via environment variables (Way 3) or a secret manager (Way 4). Environment variables use double-underscore (__) for nesting because not all platforms allow colons. The one common mistake this prevents only if you follow it: a committed production connection string with a password is a security incident the moment it is pushed, and rewriting Git history to remove it is painful and unreliable — so keep them out from the start.
Way 2: Strongly-Typed Options (IOptions pattern)
Best used for: Binding configuration sections to typed classes so your code consumes settings as strongly-typed objects with validation, rather than reading magic string keys scattered everywhere.
The concept: Instead of reading Configuration["Timeouts:ApiSeconds"] with stringly-typed keys throughout your code, you bind a configuration section to a class once and inject it via IOptions<T>. You get compile-time-checked property access, validation at startup, and a single definition of each settings group.
// A typed settings class
public class TimeoutSettings
{
public const string Section = "Timeouts";
[Range(1, 300)] public int ApiSeconds { get; set; } = 30;
[Range(1, 600)] public int DatabaseSeconds { get; set; } = 60;
}
// Bind it once, with validation that runs at STARTUP (fail fast on bad config)
builder.Services.AddOptions<TimeoutSettings>()
.Bind(builder.Configuration.GetSection(TimeoutSettings.Section))
.ValidateDataAnnotations()
.ValidateOnStart(); // app won't start with invalid config — caught immediately
// Inject the typed settings — no magic strings in consuming code
public class ApiClient(IOptions<TimeoutSettings> options)
{
private readonly TimeoutSettings _settings = options.Value;
public void Configure() => _httpClient.Timeout = TimeSpan.FromSeconds(_settings.ApiSeconds);
}
// IOptionsSnapshot<T> — re-reads per request (picks up changes without restart)
// IOptionsMonitor<T> — push notifications on change (for singletons / background services)
Why it works: Binding to a class gives you typed, discoverable, refactor-safe access to settings — properties instead of string keys — and centralises each settings group in one definition. ValidateOnStart turns invalid configuration into an immediate startup failure with a clear message, rather than a confusing runtime error when the bad value is first used (or worse, silently wrong behaviour).
Benchmark note: The options pattern is the idiomatic way to consume configuration in .NET — it eliminates scattered magic strings, gives compile-time safety, and ValidateOnStart enforces that the app cannot boot with invalid configuration (missing required values, out-of-range numbers) — failing fast at deploy time instead of failing mysteriously later. The three flavours matter: IOptions<T> (singleton, read once — most common), IOptionsSnapshot<T> (scoped, re-read per request — picks up changes), and IOptionsMonitor<T> (singleton with change notifications — for background services that must react to config changes). Use validation liberally; configuration errors caught at startup are cheap, while the same errors discovered in production at 3 AM are not.
Way 3: User Secrets (local dev) + Environment Variables (deployed)
Best used for: Keeping secrets out of source control during local development (user secrets) and supplying them to deployed environments (environment variables / container secrets) — the baseline secret-handling approach before adopting a dedicated vault.
The concept: For local development, the Secret Manager tool stores secrets in a JSON file outside the project directory (in your user profile), so they never risk being committed. For deployed environments, secrets are supplied as environment variables — set by the hosting platform (App Service settings, Kubernetes secrets, container environment) — which the layered configuration picks up automatically.
// LOCAL DEV — secrets stored outside the repo, in your user profile
// Command line (run in the project folder):
// dotnet user-secrets init
// dotnet user-secrets set "ConnectionStrings:Default" "Server=localhost;Password=devpass"
// dotnet user-secrets set "Stripe:ApiKey" "sk_test_..."
// User secrets are auto-loaded in Development — your code reads them normally:
var apiKey = builder.Configuration["Stripe:ApiKey"]; // from user secrets locally
// The secrets file lives at ~/.microsoft/usersecrets/<id>/secrets.json — NOT in the repo
// DEPLOYED — secrets come from environment variables set by the platform
// Kubernetes secret mounted as env vars, Azure App Service "Application Settings",
// AWS ECS task definition secrets, Docker --env-file, etc.
// ConnectionStrings__Default=Server=prod;Password=... (set by the platform, not in code)
// Same code reads them — the source differs per environment, the code does not:
var connStr = builder.Configuration.GetConnectionString("Default");
Why it works: User secrets solve the local-development problem — developers need real secret values to run the app, but those must not be committed, so storing them outside the project directory keeps them off Git entirely while still being auto-loaded in Development. In deployed environments, the platform's environment-variable mechanism supplies secrets, injected at runtime and never baked into the image or repo. The same configuration code reads both; only the source changes.
Benchmark note: This is the minimum responsible secret handling and costs nothing — user secrets are built into the .NET tooling, and environment variables are universally supported. It keeps secrets out of source control, which is the single most important rule. Its limitations push larger systems toward Way 4: environment variables are visible to anyone who can inspect the process or platform config, are not encrypted at rest in most setups, are not audited (no record of who accessed what), and rotating a secret means redeploying or reconfiguring every service that uses it. For small apps and teams this is adequate; for systems with compliance requirements, many services sharing secrets, or a need for rotation and audit, a dedicated secret manager (Way 4) is the next step.
Way 4: Dedicated Secret Manager (Key Vault / Secrets Manager)
Best used for: Production systems needing centralised, encrypted, audited, rotatable secret storage — Azure Key Vault, AWS Secrets Manager, HashiCorp Vault — especially with compliance requirements or many services sharing secrets.
The concept: A dedicated secret manager stores secrets encrypted, controls access via fine-grained policies, audits every access, and supports rotation. Your app authenticates to the vault (ideally via a managed identity — no secret needed to get secrets) and pulls secrets at startup or on demand. The vault becomes the single source of truth for secrets across all services.
// Azure Key Vault as a configuration source, authenticated via managed identity
builder.Configuration.AddAzureKeyVault(
new Uri("https://myapp-vault.vault.azure.net/"),
new DefaultAzureCredential()); // managed identity in Azure — NO secret to access secrets
// Key Vault secrets become configuration values — read them like any other config:
var connStr = builder.Configuration.GetConnectionString("Default"); // pulled from the vault
var apiKey = builder.Configuration["Stripe--ApiKey"]; // vault naming convention
// AWS Secrets Manager equivalent
var secret = await _secretsManager.GetSecretValueAsync(
new GetSecretValueRequest { SecretId = "prod/db/credentials" });
// The managed identity is the key insight: the app proves WHO it is to the cloud platform,
// and the platform grants vault access — so there is no "secret zero" to bootstrap access.
Why it works: Secrets live encrypted in one managed, access-controlled, audited place rather than scattered across environment configs. Access is governed by policy (which identity can read which secret), every access is logged (audit trail for compliance), and secrets can be rotated centrally — update once in the vault, and services pick up the new value without code changes. Managed identity solves the bootstrap problem elegantly: the app authenticates by being itself (a cloud-assigned identity), so there is no initial secret needed to access the secret store.
Benchmark note: A secret manager is the production-grade answer, providing the encryption, access control, auditing, and rotation that environment variables lack — essential for regulated industries and systems where many services share secrets or secrets must rotate regularly. The managed-identity approach eliminates the "secret zero" chicken-and-egg problem (how do you securely store the credential needed to access the secret store?) that plagues naive setups. The costs: a dependency on the vault's availability (cache secrets in memory at startup so a transient vault outage does not take down the app, and handle the startup-fetch failure mode), some latency on secret retrieval (hence caching), and platform specificity. For local development, you still typically use user secrets (Way 3) and reserve the vault for deployed environments — or point dev at a separate dev vault. This is where mature production systems land for secret management.
Way 5: Centralised / Dynamic Configuration (feature flags & runtime changes)
Best used for: Changing configuration without redeploying — feature flags, operational toggles, A/B test parameters, tuning values — and sharing configuration across many service instances or services from one central store.
The concept: A centralised configuration service (Azure App Configuration, AWS AppConfig, Consul) stores settings and feature flags in one place that all instances read, with the ability to change values at runtime and have applications pick them up without a redeploy. Feature flags specifically let you turn features on/off, roll out gradually, or kill a misbehaving feature instantly.
// Azure App Configuration with feature flags and dynamic refresh
builder.Configuration.AddAzureAppConfiguration(options =>
{
options.Connect(new Uri("https://myapp-config.azconfig.io"), new DefaultAzureCredential())
.UseFeatureFlags() // load feature flags
.ConfigureRefresh(refresh => refresh // poll for changes — no redeploy needed
.Register("Settings:Sentinel", refreshAll: true)
.SetRefreshInterval(TimeSpan.FromSeconds(30)));
});
builder.Services.AddAzureAppConfiguration();
builder.Services.AddFeatureManagement();
app.UseAzureAppConfiguration(); // middleware that triggers refresh
// Feature flags evaluated at runtime — flip them in the portal, app reacts within seconds
public class CheckoutController(IFeatureManager features) : ControllerBase
{
public async Task<IActionResult> Checkout()
{
if (await features.IsEnabledAsync("NewCheckout"))
return NewCheckoutFlow(); // gradually rolled out, or instantly killed if broken
return LegacyCheckoutFlow();
}
}
Why it works: Centralising configuration means all instances and services read one source of truth, eliminating configuration drift between servers. Dynamic refresh decouples configuration changes from deployments — you change a value or flip a flag in the central store and running applications pick it up within the refresh interval, no redeploy, no restart. Feature flags turn risky big-bang releases into controlled gradual rollouts with an instant kill switch if something goes wrong.
Benchmark note: Dynamic configuration and feature flags are powerful operational tools: gradual rollouts (enable a feature for 5% of users, then 50%, then 100%), instant rollback (flip a flag off the moment a feature misbehaves — far faster than a redeploy), and operational tuning (adjust a timeout or batch size in production without shipping code). The costs and cautions: a dependency on the config service (cache values and handle its unavailability gracefully — fall back to last-known-good), the discipline to retire stale flags (long-lived flags accumulate into a tangle of dead conditionals — treat flag removal as part of finishing a feature), and the testing burden that every flag combination is a code path. Used judiciously, centralised dynamic config is a major operational capability; used carelessly, feature flags become permanent technical debt. Reserve dynamic config for values that genuinely benefit from runtime change — not everything needs to be dynamic.
Choosing the right approach (and how they layer)
- Base mechanism for all settings → Layered configuration sources (Way 1)
- Consuming settings in code, typed + validated → IOptions pattern (Way 2)
- Keeping secrets out of source control (baseline) → User secrets + environment variables (Way 3)
- Production secret storage with audit + rotation → Dedicated secret manager (Way 4)
- Runtime changes without redeploy / feature flags → Centralised dynamic config (Way 5)
These compose into one configuration strategy. The layered system (Way 1) is the foundation; you consume its merged result through typed, validated options (Way 2). Secrets are kept out of source control via user secrets locally and environment variables when deployed (Way 3), graduating to a dedicated vault (Way 4) when you need encryption, audit, and rotation. Centralised dynamic config and feature flags (Way 5) sit on top for values that must change at runtime. The non-negotiable rule across all of it: secrets never go in source control — not in any appsettings file, not in code, not in commit history; they enter through environment variables or a vault at runtime. Beyond that, the principles are: validate configuration at startup so bad config fails fast, prefer typed options over magic strings, and make values dynamic only when runtime change genuinely adds value. Getting configuration discipline right is unglamorous but prevents some of the most damaging and embarrassing production incidents — leaked credentials and environment mix-ups chief among them.
Note: this is an informational engineering overview. Secret-management requirements depend on your security and compliance obligations. Always follow your organisation's security policies and never commit secrets to source control.
More from the playbooks
Mapping & Transforming Data (Entity to DTO)
You accidentally serialized a password hash to the API
Validating Input
Bad data reached your database and corrupted state
Time Zones & Dates Across Systems
The meeting shows at the wrong hour for half your users