Configuration & Secrets
The .NET configuration system -- layered providers, the Options pattern in depth, user secrets, environment variables, and Azure Key Vault
Configuration & Secrets
.NET configuration is a layered key/value system. Multiple providers -- JSON files, environment variables, command-line arguments, user secrets, Azure Key Vault -- are stacked in a defined order, and later providers override earlier ones for the same key. The host merges all of them into a single IConfiguration that the rest of your application reads from, oblivious to which provider any given value actually came from. You have seen IConfiguration once already; this page is about what happens underneath it and how to consume it without scattering brittle lookups through your code.
The mental model that prevents most configuration bugs is this: configuration is a flat dictionary with :-delimited keys, and order equals precedence. A nested JSON object is just sugar for those flattened keys. Once you internalise "flat dictionary, last writer wins," the behaviour of every provider -- including the surprising ones like environment variables overriding a JSON file -- stops being mysterious.
The Provider Chain and Precedence
The default host builder wires up a fixed chain of providers, and the rule is simply that the last one to set a key wins. The canonical order, from lowest to highest precedence, is the base appsettings.json, then the environment-specific appsettings.{Environment}.json, then user secrets (Development only), then environment variables, then command-line arguments.
// The default builder already wires these, last wins:
// 1. appsettings.json
// 2. appsettings.{Environment}.json
// 3. User Secrets (Development only)
// 4. Environment variables
// 5. Command-line argsEach layer has a job. appsettings.json holds the non-secret defaults that are true everywhere -- feature flags, timeouts, public endpoints. appsettings.{Environment}.json carries the per-environment deltas: a different log level in Staging, a different base URL in Production. User secrets fill in the developer's local secrets without touching the repo. Environment variables are how the deployment platform -- Kubernetes, App Service, a CI runner -- injects values at runtime. Command-line arguments sit on top for one-off overrides during local debugging or container entrypoints.
The concrete consequence is the layering you rely on every day. appsettings.Production.json overrides appsettings.json because it is registered after it, so a value set in both takes the Production value when ASPNETCORE_ENVIRONMENT=Production. An environment variable overrides both of those, because environment variables are registered later still -- which is exactly how you configure a container without rebuilding the image.
# This env var overrides the JSON files for the same key:
Logging__LogLevel__Default=WarningThe double underscore is not arbitrary. Hierarchical keys in JSON use : as the separator -- "Logging:LogLevel:Default" -- but : is not a legal character in environment-variable names on every platform, so the configuration system maps __ (double underscore) to :. A JSON value under "Smtp": { "Host": "..." } is the key Smtp:Host, and you set it as the environment variable Smtp__Host. Get this mapping wrong and your override silently does nothing; the key you typed simply never matches the key the binder is looking for.
Binding to Strongly-Typed Options
Reading IConfiguration["Smtp:Host"] scattered through your code is the anti-pattern. It is stringly-typed, unvalidated, and untestable, and it spreads knowledge of your configuration shape across dozens of call sites. The Options pattern is the right way to consume configuration: you define a class that mirrors a configuration section, bind the section to it once, and inject a clean, typed object everywhere else.
public class SmtpOptions
{
public string Host { get; set; } = "";
public int Port { get; set; } = 587;
public required string ApiKey { get; set; }
}
builder.Services.AddOptions<SmtpOptions>()
.Bind(builder.Configuration.GetSection("Smtp"))
.ValidateDataAnnotations()
.ValidateOnStart();AddOptions<T>() returns a builder that lets you chain binding and validation fluently, which is more powerful than the older Configure<T>(section) one-liner. Bind maps the Smtp section onto the properties by name, ValidateDataAnnotations turns [Required], [Range], and friends into runtime checks, and ValidateOnStart is the part that matters operationally.
ValidateOnStart() makes a misconfiguration fail fast at boot rather than on first use. Without it, options are validated lazily -- the first time something resolves IOptions<SmtpOptions>.Value -- which might be hours into a deployment, on the first email send, in the middle of the night. With it, a missing ApiKey or an out-of-range Port aborts startup immediately, the deployment is marked failed, and the orchestrator rolls back before any traffic hits a broken process. Failing at boot is almost always cheaper than failing in flight.
IOptions vs IOptionsSnapshot vs IOptionsMonitor
This is the comparison engineers get wrong, and the differences are about lifetime and change tracking, not convenience. Pick by where you are injecting and whether you need reloads.
IOptions<T>-- a singleton, computed once at startup and never updated. It never reflects configuration changes. Fine -- and the cheapest option -- for values fixed at startup.IOptionsSnapshot<T>-- scoped, recomputed once per request, so it picks up file reloads between requests. It cannot be injected into a singleton, because a scoped service captured by a singleton is a captive dependency.IOptionsMonitor<T>-- a singleton that always exposes the current value and supports live change notifications viaOnChange. It is usable anywhere, including singletons and background services.
The one-line rule: use IOptions<T> for config fixed at startup, IOptionsSnapshot<T> when scoped (per-request) code should see reloads, and IOptionsMonitor<T> in singletons and long-lived services that must react to changes.
public class SmtpEmailSender(IOptionsMonitor<SmtpOptions> monitor)
{
private SmtpOptions Opts => monitor.CurrentValue; // always current
}Configuration Validation
Data annotations cover the simple cases -- required, range, length -- but real validation often spans fields: a Port of 0 might be legal only when Host is empty, or two timeouts must hold an ordering. For that, implement IValidateOptions<T>, which runs as part of the same validation pipeline and can express arbitrary cross-field logic.
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
public ValidateOptionsResult Validate(string? name, SmtpOptions o)
{
if (o.Port is < 1 or > 65535)
return ValidateOptionsResult.Fail("Smtp:Port must be 1-65535.");
if (o.Host.Length == 0)
return ValidateOptionsResult.Fail("Smtp:Host is required.");
return ValidateOptionsResult.Success;
}
}
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();Combine the validator with ValidateOnStart() so the cross-field checks also run at boot. The principle to internalise: validating configuration is cheap and happens once, whereas debugging a NullReferenceException three layers deep in a production request -- caused by a config value that was empty all along -- is expensive and happens at the worst possible time. Push every check you can to startup.
Secrets: the Core Rule
Secrets never belong in appsettings.json or in source control. That file is committed, and anything committed is effectively published to everyone with repository access, forever, in the git history. The right approach is to layer secrets in differently per environment, while keeping the consuming code identical -- it always just binds a config key and never knows where the value came from.
Local development -- User Secrets. The dotnet user-secrets tool stores values in a JSON file under your user profile, outside the repository entirely, keyed by a UserSecretsId in the project file. The default host layers them in only when the environment is Development, and they bind exactly like any other configuration source.
dotnet user-secrets init
dotnet user-secrets set "Smtp:ApiKey" "local-dev-key-value"The value set above is read back through SmtpOptions.ApiKey with no code change -- it is just another provider in the chain. Crucially, it lives outside the repo, so it cannot be committed by accident.
CI and containers -- environment variables. In pipelines and deployed containers, the platform injects secrets as environment variables (Smtp__ApiKey), pulled from the CI system's secret store or the orchestrator's secret mechanism. This is acceptable for many secrets, but be aware that environment variables are visible to the process, to anything that can read /proc, and in some diagnostic and inspection surfaces -- so they are not the highest tier of protection for the most sensitive credentials.
Production -- a real secret manager. For production, use a purpose-built secret store: Azure Key Vault, AWS Secrets Manager, or HashiCorp Vault. Adding the Key Vault configuration provider makes every secret appear as an ordinary configuration key, so your binding and SmtpOptions stay unchanged -- the secret just arrives through a different provider.
builder.Configuration.AddAzureKeyVault(
new Uri($"https://{vaultName}.vault.azure.net/"),
new DefaultAzureCredential());DefaultAzureCredential combined with a Managed Identity eliminates the chicken-and-egg problem of needing "a secret to get the secrets." Instead of a bootstrap connection string or client secret stored somewhere to authenticate to the vault, the platform hands the running process an identity, and DefaultAzureCredential uses it transparently. There is no bootstrap secret to leak, rotate, or forget. This matters a great deal in New Zealand's Azure-heavy enterprise and government shops, where a clean identity-based story is often a compliance requirement rather than a nicety.
The Options Pattern with Named Options
When you need several configured instances of the same options type -- two outbound email providers, two named HttpClients, a primary and a fallback store -- named options keep them distinct. You register each configuration under a name and resolve it by that name through IOptionsSnapshot<T> or IOptionsMonitor<T>.
builder.Services.AddOptions<SmtpOptions>("primary")
.Bind(builder.Configuration.GetSection("Smtp:Primary"));
builder.Services.AddOptions<SmtpOptions>("fallback")
.Bind(builder.Configuration.GetSection("Smtp:Fallback"));
// Resolve a specific one:
var primary = monitor.Get("primary");The unnamed registration you have been using is really the named option with Options.DefaultName (the empty string). Reach for named options only when you genuinely have multiple instances; for the common single-instance case the plain AddOptions<T>() is clearer.
Sharp Edges
The failures here are predictable and worth pre-empting. A secret accidentally committed in appsettings.json is the single most common real incident -- and the response is to rotate it, not just delete it. Once a secret is pushed it lives in the git history on every clone and fork; removing the line in a later commit changes nothing, the value is already compromised and must be invalidated at the source. The double-underscore-versus-colon confusion silently breaks environment-variable overrides: Logging:LogLevel as an env var name does nothing, only Logging__LogLevel maps to the right key. Injecting IOptionsSnapshot<T> into a singleton throws at resolution because it is a captive dependency -- use IOptionsMonitor<T> in singletons instead. Config reloads do not propagate to IOptions<T> because it is a one-time startup snapshot, so code expecting runtime changes must use IOptionsSnapshot<T> or IOptionsMonitor<T>. File-based providers are case-sensitive on Linux even though they are not on Windows, so appsettings.Production.json and appsettings.production.json are two different files in a container and only one of them loads. And DefaultAzureCredential behaves differently locally than in Azure -- locally it falls back to your developer login or environment variables, in Azure it uses the Managed Identity -- which is a frequent "works on my machine" trap when the local identity has vault access but the deployed identity was never granted it.