Configuration bugs are silent until audit logs are reviewed or production breaks. The patterns in this article compile, pass tests, work in development, and then leak secrets or fail in production.
Common questions this answers
- Why do secrets appear in my application logs?
- How do I validate configuration at startup instead of runtime?
- What configuration patterns cause environment-specific failures?
- When should I use options validation vs. manual checks?
- How do I prevent insecure defaults from shipping?
Definition (what this means in practice)
Configuration anti-patterns are code structures that mishandle application settings, secrets, or environment-specific values. They often work in development but fail in production due to different environment configurations, or they silently leak sensitive data.
In practice, this means reviewing how configuration is loaded, validated, and used throughout the application lifecycle.
Terms used
- Options pattern: ASP.NET Core's typed configuration binding (
IOptions<T>) - Configuration provider: source of configuration values (appsettings.json, environment variables, Key Vault)
- Secret: sensitive configuration values that must not be logged or exposed
- Startup validation: checking configuration when the application starts, not when values are first used
Reader contract
This article is for:
- Engineers debugging configuration-related production incidents
- Teams reviewing configuration handling code
You will leave with:
- Recognition of 6 configuration anti-patterns
- Validation patterns that catch problems at startup
- Secret handling practices that prevent leaks
This is not for:
- Configuration providers deep dive (assumes familiarity with appsettings.json, environment variables)
- Azure Key Vault or AWS Secrets Manager setup
Quick start (10 minutes)
If you do nothing else, do these checks:
- Search logs for connection strings, API keys, or passwords
- Enable options validation for all
IOptions<T>configurations - Check that no hardcoded URLs or connection strings exist
- Verify Development settings don't leak to Production
- Review default values for security implications
// Enable startup validation
builder.Services.AddOptions<DatabaseOptions>()
.Bind(builder.Configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail fast at startup
Anti-pattern 1: Secrets logged in plain text
Logging configuration values or exceptions that contain connection strings exposes secrets.
The problem
// BAD: Logging secrets directly
public class DatabaseService(
IOptions<DatabaseOptions> options,
ILogger<DatabaseService> logger)
{
public void Connect()
{
logger.LogInformation(
"Connecting to database with connection string: {ConnectionString}",
options.Value.ConnectionString); // Secret in logs!
}
}
// BAD: Exception messages contain secrets
public class EmailService(IOptions<EmailOptions> options)
{
public async Task SendAsync(string to, string subject, string body)
{
try
{
await _client.SendAsync(to, subject, body);
}
catch (Exception ex)
{
// Exception.Message may contain API key
throw new EmailException(
$"Failed to send email using key {options.Value.ApiKey}", ex);
}
}
}
Why it fails
- Logs are often stored in plain text, accessible to operations teams
- Log aggregators (Splunk, ELK, Application Insights) index secret values
- Exception messages appear in error reports and monitoring dashboards
- Secrets in logs fail security audits and compliance requirements
The fix
Never log secrets. Redact sensitive values:
// GOOD: Log safe values only
public class DatabaseService(
IOptions<DatabaseOptions> options,
ILogger<DatabaseService> logger)
{
public void Connect()
{
var connectionString = options.Value.ConnectionString;
var server = ExtractServerName(connectionString);
logger.LogInformation(
"Connecting to database server: {Server}",
server); // Only safe values
}
}
// GOOD: Structured logging with redaction
public class SerilogRedactor
{
private static readonly HashSet<string> SensitiveKeys =
[
"password", "secret", "apikey", "connectionstring",
"token", "credential", "key"
];
public static LogEventPropertyValue Redact(
LogEventPropertyValue value, string propertyName)
{
if (SensitiveKeys.Contains(propertyName.ToLowerInvariant()))
{
return new ScalarValue("[REDACTED]");
}
return value;
}
}
Configure Serilog redaction
// Program.cs - configure destructuring policy
Log.Logger = new LoggerConfiguration()
.Destructure.ByTransforming<DatabaseOptions>(opts =>
new { Server = opts.Server, Database = opts.Database }) // Exclude ConnectionString
.CreateLogger();
Detection
Search logs for sensitive patterns:
grep -i "password\|secret\|apikey\|connectionstring" /var/log/app/*.log
Anti-pattern 2: Missing configuration validation
Invalid configuration discovered at runtime instead of startup causes production incidents.
The problem
// BAD: No validation - fails when first used
public class PaymentOptions
{
public string ApiKey { get; set; } = "";
public string MerchantId { get; set; } = "";
public string Endpoint { get; set; } = "";
}
// Registration without validation
builder.Services.Configure<PaymentOptions>(
builder.Configuration.GetSection("Payment"));
// Fails at runtime when payment is attempted
public class PaymentService(IOptions<PaymentOptions> options)
{
public async Task ProcessAsync(Payment payment)
{
if (string.IsNullOrEmpty(options.Value.ApiKey))
{
throw new InvalidOperationException("PaymentOptions.ApiKey not configured");
}
// ...
}
}
Why it fails
- Missing configuration isn't discovered until the code path is executed
- In production, this might be hours or days after deployment
- Users experience errors instead of the deployment failing during health checks
The fix
Use data annotations and validate on start:
// GOOD: Validated configuration
public class PaymentOptions
{
[Required]
[MinLength(10)]
public required string ApiKey { get; set; }
[Required]
public required string MerchantId { get; set; }
[Required]
[Url]
public required string Endpoint { get; set; }
[Range(1, 300)]
public int TimeoutSeconds { get; set; } = 30;
}
// Registration with validation
builder.Services.AddOptions<PaymentOptions>()
.Bind(builder.Configuration.GetSection("Payment"))
.ValidateDataAnnotations()
.ValidateOnStart(); // Fail immediately if invalid
Complex validation with IValidateOptions
// GOOD: Custom validation logic
public class PaymentOptionsValidator : IValidateOptions<PaymentOptions>
{
public ValidateOptionsResult Validate(string? name, PaymentOptions options)
{
var failures = new List<string>();
if (!options.Endpoint.StartsWith("https://"))
{
failures.Add("Payment endpoint must use HTTPS");
}
if (options.ApiKey.StartsWith("test_") &&
Environment.GetEnvironmentVariable("ASPNETCORE_ENVIRONMENT") == "Production")
{
failures.Add("Test API key cannot be used in Production");
}
return failures.Count > 0
? ValidateOptionsResult.Fail(failures)
: ValidateOptionsResult.Success;
}
}
// Register validator
builder.Services.AddSingleton<IValidateOptions<PaymentOptions>, PaymentOptionsValidator>();
Detection
Search for Configure<T> without ValidateDataAnnotations:
grep -rn "Configure<" --include="*.cs" | grep -v "ValidateDataAnnotations\|ValidateOnStart"
Anti-pattern 3: Hardcoded values that should be configuration
Hardcoded URLs, connection strings, or environment-specific values require recompilation to change.
The problem
// BAD: Hardcoded URL
public class ReportService
{
private const string ReportApiUrl = "https://reports.internal.company.com/api";
public async Task<Report> GenerateAsync(ReportRequest request)
{
using var client = new HttpClient();
var response = await client.PostAsJsonAsync(
$"{ReportApiUrl}/generate", // Different in each environment!
request);
// ...
}
}
// BAD: Hardcoded retry count
public class OrderService
{
public async Task ProcessAsync(Order order)
{
for (var i = 0; i < 3; i++) // What if we need 5 retries?
{
try
{
await ProcessOrderAsync(order);
return;
}
catch { }
}
}
}
Why it fails
- Different environments (dev, staging, production) have different URLs
- Changing the value requires code change, build, and deployment
- Configuration changes should be possible without deployment
- Hardcoded values hide dependencies on external systems
The fix
Extract to configuration:
// GOOD: Configuration-driven
public class ReportServiceOptions
{
[Required]
[Url]
public required string ApiUrl { get; set; }
[Range(1, 10)]
public int MaxRetries { get; set; } = 3;
[Range(1, 300)]
public int TimeoutSeconds { get; set; } = 30;
}
public class ReportService(
IOptions<ReportServiceOptions> options,
HttpClient httpClient)
{
public async Task<Report> GenerateAsync(ReportRequest request)
{
var response = await httpClient.PostAsJsonAsync(
$"{options.Value.ApiUrl}/generate",
request);
// ...
}
}
What should be configuration
| Always Configure | Usually Hardcode |
|---|---|
| URLs and endpoints | Algorithm implementations |
| Connection strings | Business logic |
| Retry counts/timeouts | Data structures |
| Feature flags | Security validation rules |
| Rate limits | Error messages (consider localization) |
| Page sizes | HTTP methods |
Detection
Search for hardcoded patterns:
grep -rn "https://\|http://\|localhost:" --include="*.cs" | grep -v "test\|Test"
grep -rn "\.com\|\.net\|\.internal" --include="*.cs"
Anti-pattern 4: Environment configuration leakage
Development settings leaking to production cause security and functionality issues.
The problem
// appsettings.json (base configuration)
{
"Database": {
"ConnectionString": "Server=localhost;Database=App_Dev;Trusted_Connection=true"
},
"Logging": {
"LogLevel": {
"Default": "Debug" // Too verbose for production
}
},
"AllowedHosts": "*" // Too permissive for production
}
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// BAD: Relying on environment-specific files without validation
// If appsettings.Production.json is missing or incomplete,
// production uses development settings
Why it fails
appsettings.jsonvalues are used if environment-specific file doesn't override them- Missing
appsettings.Production.jsonmeans production uses base settings - Development debug settings appear in production logs
- Permissive CORS/host settings expose security vulnerabilities
The fix
Use environment-specific required settings and validate:
// Program.cs
var builder = WebApplication.CreateBuilder(args);
// Require environment-specific configuration for critical settings
if (builder.Environment.IsProduction())
{
var connectionString = builder.Configuration.GetConnectionString("Default");
if (string.IsNullOrEmpty(connectionString) ||
connectionString.Contains("localhost") ||
connectionString.Contains("_Dev"))
{
throw new InvalidOperationException(
"Production environment requires production database connection string");
}
}
// Or use options validation
public class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
{
private readonly IWebHostEnvironment _environment;
public DatabaseOptionsValidator(IWebHostEnvironment environment)
{
_environment = environment;
}
public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
{
if (_environment.IsProduction())
{
if (options.ConnectionString.Contains("localhost"))
{
return ValidateOptionsResult.Fail(
"Production cannot use localhost database");
}
}
return ValidateOptionsResult.Success;
}
}
Environment configuration structure
appsettings.json # Safe defaults only
appsettings.Development.json # Dev-specific overrides
appsettings.Production.json # Production overrides (never commit secrets)
Detection
Check for development values in production:
// Startup validation
if (builder.Environment.IsProduction())
{
var config = builder.Configuration;
// Check for development indicators
var redFlags = new List<string>();
if (config["Logging:LogLevel:Default"] == "Debug")
redFlags.Add("Debug logging enabled in production");
if (config["AllowedHosts"] == "*")
redFlags.Add("AllowedHosts allows any host in production");
if (redFlags.Any())
{
throw new InvalidOperationException(
$"Production configuration issues: {string.Join(", ", redFlags)}");
}
}
Anti-pattern 5: Insecure default values
Default values that work in development create security vulnerabilities in production.
The problem
// BAD: Insecure defaults
public class SecurityOptions
{
public bool RequireHttps { get; set; } = false; // Insecure default
public string[] AllowedOrigins { get; set; } = ["*"]; // Too permissive
public int TokenExpirationMinutes { get; set; } = 10080; // 7 days is too long
public bool ValidateIssuer { get; set; } = false; // Disables validation
}
// BAD: Default connection string
public class DatabaseOptions
{
public string ConnectionString { get; set; } =
"Server=localhost;Database=Dev;Integrated Security=true";
}
Why it fails
- Developers might not override defaults in production configuration
- Security settings disabled by default remain disabled
- Default values become the "path of least resistance"
- Security audits flag permissive defaults
The fix
Use secure defaults or require explicit configuration:
// GOOD: Secure defaults
public class SecurityOptions
{
public bool RequireHttps { get; set; } = true; // Secure by default
public string[] AllowedOrigins { get; set; } = []; // Must be explicitly configured
public int TokenExpirationMinutes { get; set; } = 60; // Reasonable default
public bool ValidateIssuer { get; set; } = true; // Secure by default
}
// GOOD: No default for sensitive values
public class DatabaseOptions
{
[Required]
public required string ConnectionString { get; set; } // Must be configured
}
// GOOD: Environment-aware defaults
public class CorsOptions
{
public string[] AllowedOrigins { get; set; } = [];
}
// Validation
public class CorsOptionsValidator : IValidateOptions<CorsOptions>
{
private readonly IWebHostEnvironment _env;
public CorsOptionsValidator(IWebHostEnvironment env) => _env = env;
public ValidateOptionsResult Validate(string? name, CorsOptions options)
{
if (_env.IsProduction() && options.AllowedOrigins.Contains("*"))
{
return ValidateOptionsResult.Fail(
"Wildcard CORS origins not allowed in production");
}
if (_env.IsProduction() && options.AllowedOrigins.Length == 0)
{
return ValidateOptionsResult.Fail(
"CORS origins must be explicitly configured in production");
}
return ValidateOptionsResult.Success;
}
}
Default value guidelines
| Setting Type | Guideline |
|---|---|
| Security toggles | Default to secure (enabled) |
| Connection strings | No default, require explicit config |
| Timeout values | Conservative defaults (30s, not 5min) |
| Rate limits | Restrictive defaults |
| Logging levels | Information or Warning for production |
| CORS origins | Empty array, require explicit config |
Detection
Review options classes for defaults:
grep -rn "{ get; set; } =" --include="*.cs" | grep "Options"
Anti-pattern 6: Configuration reload without handling
Configuration changes at runtime can cause inconsistent state.
The problem
// BAD: Cached configuration doesn't see updates
public class CachingService
{
private readonly CacheOptions _options;
public CachingService(IOptions<CacheOptions> options)
{
_options = options.Value; // Captured once, never updates
}
}
// BAD: No coordination during reload
public class ConnectionPool
{
private readonly IOptionsMonitor<DatabaseOptions> _options;
private SqlConnection? _connection;
public ConnectionPool(IOptionsMonitor<DatabaseOptions> options)
{
_options = options;
_options.OnChange(HandleConfigChange); // Race condition!
}
private void HandleConfigChange(DatabaseOptions newOptions)
{
// Old connection might be in use when this runs
_connection?.Dispose();
_connection = new SqlConnection(newOptions.ConnectionString);
}
public SqlConnection GetConnection() => _connection!; // Might be disposed!
}
Why it fails
IOptions<T>captures configuration once at injection timeIOptionsMonitor<T>provides updates but handling is tricky- Configuration changes during active requests can cause errors
- No coordination between components using the same configuration
The fix
Use IOptionsSnapshot<T> for per-request configuration, or handle reload carefully:
// GOOD: Per-request configuration (scoped)
public class CachingService(IOptionsSnapshot<CacheOptions> options)
{
public void CacheItem(string key, object value)
{
var ttl = TimeSpan.FromMinutes(options.Value.DefaultTtlMinutes);
_cache.Set(key, value, ttl);
}
}
// GOOD: Thread-safe reload handling
public class ConnectionPool : IDisposable
{
private readonly IOptionsMonitor<DatabaseOptions> _options;
private readonly SemaphoreSlim _lock = new(1, 1);
private SqlConnection? _connection;
private string? _currentConnectionString;
public ConnectionPool(IOptionsMonitor<DatabaseOptions> options)
{
_options = options;
_options.OnChange(async opts => await HandleConfigChangeAsync(opts));
}
public async Task<SqlConnection> GetConnectionAsync()
{
await _lock.WaitAsync();
try
{
var connectionString = _options.CurrentValue.ConnectionString;
if (_connection == null || _currentConnectionString != connectionString)
{
_connection?.Dispose();
_connection = new SqlConnection(connectionString);
_currentConnectionString = connectionString;
}
return _connection;
}
finally
{
_lock.Release();
}
}
private async Task HandleConfigChangeAsync(DatabaseOptions newOptions)
{
// Connection will be recreated on next GetConnectionAsync call
await _lock.WaitAsync();
try
{
_currentConnectionString = null; // Force recreation
}
finally
{
_lock.Release();
}
}
public void Dispose()
{
_connection?.Dispose();
_lock.Dispose();
}
}
Options interface comparison
| Interface | Lifetime | Updates | Use When |
|---|---|---|---|
IOptions<T> |
Singleton | Never | Configuration is static |
IOptionsSnapshot<T> |
Scoped | Per-request | Configuration may change between requests |
IOptionsMonitor<T> |
Singleton | Real-time | Need to react to changes immediately |
Detection
grep -rn "IOptions<" --include="*.cs"
# Check if captured to field vs used directly
Copy/paste artifact: configuration validation setup
// Program.cs
using System.ComponentModel.DataAnnotations;
// Options classes with validation
public class DatabaseOptions
{
[Required]
public required string ConnectionString { get; set; }
[Range(1, 300)]
public int CommandTimeoutSeconds { get; set; } = 30;
}
public class SecurityOptions
{
[Required]
public required string JwtSecret { get; set; }
[Range(1, 1440)]
public int TokenExpirationMinutes { get; set; } = 60;
public bool RequireHttps { get; set; } = true;
}
// Registration with validation
builder.Services.AddOptions<DatabaseOptions>()
.Bind(builder.Configuration.GetSection("Database"))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddOptions<SecurityOptions>()
.Bind(builder.Configuration.GetSection("Security"))
.ValidateDataAnnotations()
.ValidateOnStart();
// Custom validators
builder.Services.AddSingleton<IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();
builder.Services.AddSingleton<IValidateOptions<SecurityOptions>, SecurityOptionsValidator>();
// Environment-aware validator
public class DatabaseOptionsValidator(IWebHostEnvironment env)
: IValidateOptions<DatabaseOptions>
{
public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
{
if (env.IsProduction())
{
if (options.ConnectionString.Contains("localhost") ||
options.ConnectionString.Contains("(localdb)"))
{
return ValidateOptionsResult.Fail(
"Production cannot use local database");
}
}
return ValidateOptionsResult.Success;
}
}
Copy/paste artifact: configuration code review checklist
Configuration Code Review Checklist
1. Secret handling
- [ ] No secrets logged (connection strings, API keys, tokens)
- [ ] Secrets use secure storage (Key Vault, environment variables)
- [ ] Exception messages don't include secrets
- [ ] Serilog/logging configured to redact sensitive properties
2. Validation
- [ ] All IOptions<T> use ValidateDataAnnotations
- [ ] ValidateOnStart enabled for critical configuration
- [ ] Required attributes on mandatory settings
- [ ] Range/Url/RegularExpression attributes where appropriate
3. Defaults
- [ ] Security settings default to secure (enabled)
- [ ] No default connection strings
- [ ] Timeouts have conservative defaults
- [ ] CORS/host settings require explicit configuration
4. Environment isolation
- [ ] No development values in base appsettings.json
- [ ] Production-specific validation rules
- [ ] Environment-specific files for all environments
- [ ] Startup checks for environment mismatches
5. Hardcoded values
- [ ] No hardcoded URLs or endpoints
- [ ] No hardcoded retry counts or timeouts
- [ ] Configuration for anything that varies by environment
6. Options lifetime
- [ ] IOptionsSnapshot for per-request configuration
- [ ] IOptionsMonitor reload handlers are thread-safe
- [ ] Configuration capture (to fields) is intentional
Common failure modes
- Secret exposure: API keys appear in Application Insights or log files
- Runtime configuration errors: Missing settings discovered during user requests
- Environment contamination: Development database used in production
- Security regression: HTTPS disabled, CORS wide open after deployment
- Stale configuration: Runtime changes not picked up by application
Checklist
- Logging configured to redact sensitive values
- All options classes have ValidateOnStart enabled
- No default values for security-sensitive settings
- Environment-specific validation in place
- No hardcoded URLs or connection strings
- Options lifetime matches usage pattern
FAQ
Should I use environment variables or appsettings.json?
Both. Use appsettings.json for non-sensitive defaults, environment variables for environment-specific values and secrets. In production, prefer managed secret stores (Azure Key Vault, AWS Secrets Manager).
When should I use ValidateOnStart vs. runtime validation?
Use ValidateOnStart for all configuration. It catches problems immediately during deployment instead of during user requests.
Is IOptionsMonitor worth the complexity?
Only if you need real-time configuration updates without restart. Most applications don't need this - IOptionsSnapshot (per-request) is usually sufficient.
How do I handle configuration for integration tests?
Use appsettings.Testing.json and set ASPNETCORE_ENVIRONMENT=Testing. Override specific values in test setup using ConfigureAppConfiguration.
Should I encrypt configuration values in appsettings.json?
No. Use proper secret management (Azure Key Vault, environment variables, user-secrets for development). Encrypted strings in config files are security theater.
What to do next
Review your options classes today. Add ValidateDataAnnotations and ValidateOnStart to every configuration binding.
For more on building production-quality ASP.NET Core applications, read Structured Logging with Serilog.
If you want help reviewing configuration patterns in your codebase, reach out via Contact.
References
- Options pattern in ASP.NET Core
- Configuration in ASP.NET Core
- Safe storage of app secrets in development
- Options validation
- IOptionsMonitor
Author notes
Decisions:
- Recommend ValidateOnStart for all options. Rationale: fail-fast during deployment prevents runtime errors.
- Default security settings to secure (enabled). Rationale: developers must explicitly disable security, creating audit trail.
- Require configuration for sensitive values (no defaults). Rationale: prevents accidental use of development values in production.
Observations:
- Teams add logging without realizing connection strings are included.
- ValidateOnStart not used because developers didn't know it existed.
- Production incidents from missing appsettings.Production.json file.
- Security features disabled "temporarily" in development, never re-enabled.