Configuration looks simple. You put values in appsettings.json, read them in your app, and move on. Most developers treat it as solved infrastructure — the boring part you set up once and forget.
That's exactly why it goes wrong.
Unlike a null reference exception or a failed build, configuration mistakes are quiet. They don't announce themselves. They show up as an outage at 2 AM, a security breach traced back to a committed API key, or a production bug that disappears when you restart the container. By the time you find them, the damage is done.
These are the mistakes I've seen developers make after years of writing production code — people who knew the language well, shipped real systems, and still got this wrong. Sometimes for years without it biting them. That's the insidious part about configuration mistakes. They don't fail loudly. They wait.
The Mistakes
❌ Mistake 1: Storing Secrets in appsettings.json
This is the one that can end careers. Even experienced developers do it — usually under deadline pressure or because "it's just the dev environment."
{
"ConnectionStrings": {
"DefaultConnection": "Server=prod-db;Database=Orders;User=admin;Password=SuperSecret123!"
},
"Stripe": {
"SecretKey": "sk_live_abc123xyz"
}
}
The moment this file touches Git, you have a problem. Git history is forever. Even if you delete the file in the next commit, the secret is still in the history. Public repos get scraped by bots within minutes — AWS credentials especially. Attackers run up thousands of dollars in compute charges before you even notice.
But it's not just public repos. Private repos get breached. Contractors leave. CI/CD pipelines log environment details. The blast radius of a committed secret is larger than most developers realize.
⚠️ Why it's bad:
- Secrets leak into Git history permanently
- Local dev machines become security liabilities
- Rotating secrets means changing code and redeploying
- One breach exposes every environment that uses that file
The real issue: appsettings.json is for configuration data — timeouts, feature flags, URLs. Secrets are not configuration data. They belong in a secret store.
✅ The right way:
For local development, use User Secrets:
dotnet user-secrets init
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "your-connection-string"
dotnet user-secrets set "Stripe:SecretKey" "sk_test_yourkey"
User Secrets live outside your project directory and never touch source control.
For production, use environment variables or a dedicated secret store. The good news: WebApplication.CreateBuilder(args) already wires this up. User Secrets (Development only) and environment variables are in the default provider stack — you don't add anything. Set the environment variable on the server and it overrides the JSON files automatically.
One naming gotcha that bites everyone on Linux and in containers: hierarchical keys use a double underscore, not a colon. ConnectionStrings:DefaultConnection becomes ConnectionStrings__DefaultConnection as an environment variable. Most shells — bash included — won't accept a colon in a variable name, so use __ and .NET translates it back for you.
For a dedicated vault, append it after the defaults:
// Added last = wins over everything before it
builder.Configuration.AddAzureKeyVault(...);
Azure Key Vault, AWS Secrets Manager, and HashiCorp Vault are all production-grade options depending on your stack. The pattern is the same: secrets come from outside the codebase.
Rule: if you'd be embarrassed reading it aloud in a meeting, it doesn't belong in appsettings.json.
❌ Mistake 2: Binding Configuration to POCOs Incorrectly
The Options pattern is the professional way to consume configuration in .NET. Most developers know it exists. Far fewer use it correctly.
Here's what incorrect binding looks like:
// ❌ Reading raw strings directly
var timeout = _configuration["HttpClient:TimeoutSeconds"]; // Returns null silently
var maxRetries = int.Parse(_configuration["HttpClient:MaxRetries"]); // Throws if missing
// ❌ Binding to the wrong section
builder.Services.Configure<HttpClientSettings>(
builder.Configuration); // Bound to root, not the HttpClient section
The first returns null without warning you the key is missing. The second binds successfully but every property is null or default because you bound to the wrong section.
Here's the correct pattern:
// Your settings class
public class HttpClientSettings
{
public int TimeoutSeconds { get; set; } = 30;
public int MaxRetries { get; set; } = 3;
public string BaseUrl { get; set; } = string.Empty;
}
// appsettings.json
{
"HttpClient": {
"TimeoutSeconds": 60,
"MaxRetries": 5,
"BaseUrl": "https://api.example.com"
}
}
// ✅ Correct binding — section name must match the JSON key exactly
builder.Services.Configure<HttpClientSettings>(
builder.Configuration.GetSection("HttpClient"));
Then inject it correctly:
public class ApiClient
{
private readonly HttpClientSettings _settings;
// ✅ IOptions<T> for settings that don't change at runtime
public ApiClient(IOptions<HttpClientSettings> options)
{
_settings = options.Value;
}
}
⚠️ Why it's bad:
- Wrong section name means every property is null or default — silently
IConfiguration["key"]string access gives you no type safety and no compile-time checking- Values don't update when expected in containerized environments
- Refactoring config keys breaks things at runtime, not compile time
The real issue: Configuration binding is unforgiving. One wrong section name and everything silently fails — no exception, no warning, just defaults. Strongly typed options with the correct section name are the only safe approach.
❌ Mistake 3: Not Validating Configuration at Startup
You can get every other mistake right and still blow up in production if you don't verify your configuration is correct before the app starts serving requests.
What happens without validation:
// App starts fine ✓
// Health checks pass ✓
// Deployment succeeds ✓
// First request hits the payment service...
public class PaymentService
{
private readonly StripeClient _client;
public PaymentService(IOptions<StripeSettings> options)
{
// StripeSettings.SecretKey is empty because the environment
// variable wasn't set on the new server
_client = new StripeClient(options.Value.SecretKey);
}
public async Task<PaymentResult> ChargeAsync(decimal amount)
{
// ❌ Throws here, three layers deep, with a cryptic Stripe error
// not a "SecretKey is missing" error
return await _client.Charge(amount); // simplified — not the real Stripe API
}
}
The app starts successfully. It passes health checks. The error message points at Stripe, not at the missing configuration. In containerized environments, the app reports healthy right up until real traffic hits it.
✅ The right way — fail fast and loudly:
public class StripeSettings
{
[Required]
public string SecretKey { get; set; } = string.Empty;
[Required]
[Url]
public string WebhookUrl { get; set; } = string.Empty;
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
}
// In Program.cs
builder.Services
.AddOptions<StripeSettings>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart(); // ← This is the key line
With ValidateOnStart(), if SecretKey is missing or empty, the app throws an OptionsValidationException immediately at startup — before it serves a single request. The error message tells you exactly what's wrong and where.
Starting successfully is not the same as being configured correctly. Two method calls are the difference between finding out at startup and finding out in production at 2 AM.
Those are the three that cause the most damage. These next six are shorter — but I've seen every one of them take down a production environment.
❌ Mistake 4: Not Knowing Which Layer Won
Most developers think configuration comes from one place. It doesn't. .NET merges configuration from a layered hierarchy, and the last provider added wins. With WebApplication.CreateBuilder(args) in .NET 6+, the default order is:
appsettings.jsonappsettings.{Environment}.json- User Secrets (Development only)
- Environment variables
- Command-line args
External providers like Azure Key Vault, AWS Secrets Manager, or Azure App Configuration land wherever you add them — and since CreateBuilder has already added the defaults, anything you Add goes last. Last wins. Call builder.Configuration.AddAzureKeyVault(...) and Key Vault overrides everything, command-line args included. Getting a provider in earlier takes deliberate effort (Sources.Insert). Position is a choice — know where yours landed.
"It works on my machine" is almost always a layer problem. An environment variable on the server is silently overriding a value in appsettings.Production.json. A developer's User Secret is shadowing a shared team value. The wrong layer won, and you don't know which one.
When debugging, builder.Configuration.GetDebugView() shows you exactly which provider supplied each value and in what order. Use it.
❌ Mistake 5: Putting Logic in Configuration
Configuration is for data. Not behavior.
// ❌ Logic disguised as configuration
{
"DiscountStrategy": "TieredPercentage",
"RetryPolicy": "ExponentialBackoff",
"AuthMode": "OAuth2WithPKCE"
}
These aren't values — they're decisions. When you put decisions in configuration, you lose compile-time safety, you create magic strings that can drift from the code that consumes them, and you build a second codebase in JSON that has no type system and no tests.
// ✅ Data belongs in configuration
{
"DiscountRate": 0.15,
"MaxRetryAttempts": 3,
"TokenExpiryMinutes": 60
}
Logic belongs in code. Data belongs in configuration. The line is clearer than most developers draw it.
❌ Mistake 6: IOptionsSnapshot<T> in Singleton Services
This one causes bugs that are hard to diagnose — and the behavior depends on your setup.
IOptionsSnapshot<T> is registered as Scoped — it's designed to reflect configuration changes per request. If you inject it into a Singleton service, the default host catches you at startup: builder.Build() throws InvalidOperationException before a single request is served, because WebApplication.CreateBuilder turns on both ValidateScopes and ValidateOnBuild in Development. Both are off by default in Production. When it fires, that's the good outcome — you find out immediately.
The dangerous scenarios are the ones scope validation doesn't cover: Production deploys, custom hosts that don't enable validation, and BackgroundService classes that create one long-lived scope at startup and resolve everything through it. (Scope-per-unit-of-work is the correct pattern — the trap is the scope that never dies.) There, the Singleton silently captures the first scoped instance it receives and holds onto it forever. Your configuration never updates. No exception. No warning. Just stale values in production.
// ❌ Singleton capturing a scoped service
builder.Services.AddSingleton<BackgroundProcessor>();
public class BackgroundProcessor
{
// This will never reflect config changes
public BackgroundProcessor(IOptionsSnapshot<ProcessorSettings> options) { }
}
The rule:
- Singleton services →
IOptionsMonitor<T>(supports change notifications) - Scoped services →
IOptionsSnapshot<T>(refreshes per request) - Transient services →
IOptionsSnapshot<T>when consumed from a scope (typical HTTP request path), orIOptions<T>if you don't need per-request refresh. NeverIOptionsSnapshot<T>if the transient might be resolved from a Singleton — same captive-dependency trap.
❌ Mistake 7: ReloadOnChange in Containers and Cloud Environments
reloadOnChange: true sounds like a good idea. In local development, it is. In containers, Kubernetes, Azure App Service, or any read-only file system, it silently fails.
File watchers depend on file system events. Many cloud environments mount configuration files as read-only volumes that don't emit those events. Your app thinks it supports hot reload. It doesn't. Config changes don't apply until a full redeploy, and you have no idea why.
In cloud environments, use environment variables, Azure App Configuration, or Key Vault — not file watchers. Set reloadOnChange: false and be explicit about it.
❌ Mistake 8: Mutating IConfiguration at Runtime
This one is rare but catastrophic when it happens — usually in codebases where someone decided configuration needed to be "dynamic."
The mistake is adding or modifying configuration providers after the host has built, typically inside middleware or a service constructor. It feels clever. It isn't.
Configuration providers aren't thread-safe. Mutate the tree after startup and two requests can read two different values at the same instant. Reload tokens fire unpredictably. Services that cached config at startup drift out of sync with services that didn't. And debugging it is misery — the configuration state stops being deterministic, so the bug never reproduces the same way twice.
// ❌ Modifying configuration after the host is built
var app = builder.Build();
app.Use(async (context, next) =>
{
// Dynamically forcing all providers to reload mid-request
// (only IConfiguration is registered — you have to cast to reach Reload)
((IConfigurationRoot)context.RequestServices
.GetRequiredService<IConfiguration>())
.Reload(); // Race condition: other requests may be mid-read
await next();
});
// ✅ If you need values that change at runtime, use IOptionsMonitor<T>
public class MyService
{
private readonly IOptionsMonitor<MySettings> _options;
public MyService(IOptionsMonitor<MySettings> options)
{
_options = options;
}
public void DoWork()
{
// Always reads the current value safely
var settings = _options.CurrentValue;
}
}
Configuration should be immutable after startup. If you need runtime-configurable values, that's what IOptionsMonitor<T> is for — it handles change notification safely through the proper channel.
❌ Mistake 9: Reading Configuration Eagerly at Startup
Order matters in Program.cs — but not where most developers think it does.
Options binding is lazy. When you call .Bind(builder.Configuration.GetSection("X")), nothing is read yet — the bind runs at first resolution, after Build(), against the live configuration. Bind before you add Key Vault, bind after, same result. That part is forgiving.
What's not forgiving: eager reads. The moment you write builder.Configuration["key"] and use the value during startup, you've taken a snapshot. Providers added after that line never get a vote.
// ❌ Eager read — snapshots the connection string before Key Vault exists
var connectionString = builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContext<OrdersContext>(o => o.UseSqlServer(connectionString));
builder.Configuration.AddAzureKeyVault(...); // Too late. The DbContext never sees this.
// ✅ Add providers first, read after
builder.Configuration.AddAzureKeyVault(...);
var connectionString = builder.Configuration["ConnectionStrings:DefaultConnection"];
builder.Services.AddDbContext<OrdersContext>(o => o.UseSqlServer(connectionString));
Same trap, different costumes: .GetSection("X").Get<T>() snapshots the instant you call it. Registration logic that branches on a config value — if (builder.Configuration["FeatureX"] == "true") — decides based on whatever providers exist at that line. Lazy binding through AddOptions<T>().Bind(...) is safe. Eager reads are not.
It's a sequencing mistake that produces symptoms far removed from the cause. The value from appsettings.json works in dev. The Key Vault value never loads in production. Nothing logs a complaint. Exactly the kind of bug that costs hours to diagnose.
✅ The Gold Standard Setup
Here's the setup used by teams who ship without configuration surprises. The surprise: it's mostly the setup you already have.
WebApplication.CreateBuilder(args) already adds every default provider, in the right order: appsettings.json, then appsettings.{Environment}.json, then User Secrets (Development only), then environment variables, then command-line args. Last wins. Don't re-add any of it — manually rebuilding the stack is how teams accidentally invert their own precedence.
The gold standard: rely on the defaults, append only what's missing.
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// 1. The default providers are already wired up — don't re-add them.
// Append only the extras. Added last = wins over everything, which is
// exactly what you want from a vault.
builder.Configuration.AddAzureKeyVault(...);
// or on AWS (community package: Kralizek.Extensions.Configuration.AWSSecretsManager):
// builder.Configuration.AddSecretsManager();
// 2. Strongly typed options with startup validation
// Note: .BindConfiguration("Database") — around since .NET 5 — is shorthand
// for the .Bind(builder.Configuration.GetSection("Database")) call below.
builder.Services
.AddOptions<DatabaseSettings>()
.Bind(builder.Configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services
.AddOptions<StripeSettings>()
.Bind(builder.Configuration.GetSection("Stripe"))
.ValidateDataAnnotations()
.ValidateOnStart();
// 3. Inject correctly based on lifetime
// Singleton → IOptionsMonitor<T>
// Scoped → IOptionsSnapshot<T>
// Transient → IOptionsSnapshot<T> (or IOptions<T> if config reload isn't needed)
Your settings classes:
public class DatabaseSettings
{
[Required]
public string ConnectionString { get; set; } = string.Empty;
[Range(1, 300)]
public int CommandTimeoutSeconds { get; set; } = 30;
}
public class StripeSettings
{
[Required]
public string SecretKey { get; set; } = string.Empty;
[Required]
[Url]
public string WebhookUrl { get; set; } = string.Empty;
}
If you want to guard against the entire section being missing — not just individual properties — add a .Validate() call with an explicit value check before .ValidateOnStart():
builder.Services
.AddOptions<StripeSettings>()
.Bind(builder.Configuration.GetSection("Stripe"))
.Validate(s => !string.IsNullOrEmpty(s.SecretKey),
"Stripe:SecretKey is missing — check environment variables or Key Vault.")
.ValidateDataAnnotations()
.ValidateOnStart();
A note on why this uses an explicit string check instead of a null check: when a section is entirely missing, Bind() still creates a default-constructed object with empty/default values. The object is never null. [Required] on string properties catches empty strings, so ValidateDataAnnotations() already covers most missing-section cases. Explicit .Validate() is for non-string properties where a default value like 0 could be ambiguous.
No magic strings. No silent nulls. No runtime surprises. The app either starts correctly configured or it doesn't start at all.
🧭 Key Takeaways
- Secrets belong in User Secrets locally, environment variables or a vault in production — never in
appsettings.json - Bind to the correct section by name using strongly typed options
- Validate at startup with
ValidateDataAnnotations()andValidateOnStart() - The provider hierarchy is layered — order matters and the last provider wins
- Configuration is for data, not behavior
- Match your Options interface to your service lifetime
Next steps: Find one place in your codebase where you're reading configuration with _configuration["SomeKey"]. Replace it with a strongly typed options class, bind it correctly, and add startup validation. The difference is immediate.
In the next post, we'll build a complete WebAPI controller using these patterns — and show you how to structure your endpoints so they're testable, consistent, and production-ready from day one.