Configuration validation ensures that a deployment with missing or invalid settings fails immediately at startup with a clear error message, rather than failing minutes or hours later when a specific feature is first used. Combined with the IOptions pattern from Chapter 18, configuration validation is one of the highest-value defensive practices in production ASP.NET Core: it transforms “the app crashed after 20 minutes with a NullReferenceException” into “the app refused to start with: ‘JwtSettings.SecretKey is required.'”
DataAnnotations Validation
// โโ Annotate options class with validation attributes โโโโโโโโโโโโโโโโโโโโโ
public class JwtSettings
{
public const string SectionName = "JwtSettings";
[Required(ErrorMessage = "JWT Issuer is required.")]
[Url(ErrorMessage = "JWT Issuer must be a valid URL.")]
public string Issuer { get; init; } = string.Empty;
[Required]
[Url]
public string Audience { get; init; } = string.Empty;
[Required]
[MinLength(32, ErrorMessage = "JWT SecretKey must be at least 32 characters.")]
public string SecretKey { get; init; } = string.Empty;
[Range(1, 1440, ErrorMessage = "ExpiryMinutes must be between 1 and 1440 (24 hours).")]
public int ExpiryMinutes { get; init; } = 60;
}
// โโ Register with validation โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
builder.Services
.AddOptions<JwtSettings>()
.BindConfiguration(JwtSettings.SectionName)
.ValidateDataAnnotations() // enables DataAnnotations checks
.ValidateOnStart(); // runs checks at startup, not on first access
ValidateOnStart(), validation runs lazily โ only when the options are first accessed. If the first access happens inside a request handler 20 minutes after deployment, the first user to trigger that code path experiences the failure. ValidateOnStart() registers the options as a hosted service check that runs during IHost.StartAsync(), before any HTTP requests are served. The host refuses to start if validation fails, making misconfigured deployments immediately obvious.IValidateOptions<T>: public class SmtpOptionsValidator : IValidateOptions<SmtpOptions> { public ValidateOptionsResult Validate(string? name, SmtpOptions opts) { if (opts.UseSsl && opts.Port == 25) return ValidateOptionsResult.Fail("Port 25 cannot be used with SSL."); return ValidateOptionsResult.Success; } }. Register it: builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>()._logger.LogInformation("SMTP configured: Host={Host}, Port={Port}, SSL={Ssl}", opts.Host, opts.Port, opts.UseSsl) โ omitting Password.IValidateOptions โ Complex Cross-Property Validation
// โโ Custom validator for cross-property rules โโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
public class SmtpOptionsValidator : IValidateOptions<SmtpOptions>
{
public ValidateOptionsResult Validate(string? name, SmtpOptions opts)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(opts.Host))
errors.Add("Smtp.Host is required.");
if (opts.Port is < 1 or > 65535)
errors.Add($"Smtp.Port {opts.Port} is out of valid range (1-65535).");
if (opts.UseSsl && opts.Port == 25)
errors.Add("Port 25 cannot be used with SSL/TLS. Use 465 or 587.");
if (string.IsNullOrWhiteSpace(opts.Username) != string.IsNullOrWhiteSpace(opts.Password))
errors.Add("Smtp.Username and Smtp.Password must both be set or both be empty.");
return errors.Count > 0
? ValidateOptionsResult.Fail(errors)
: ValidateOptionsResult.Success;
}
}
// โโ Register โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
builder.Services.AddSingleton<IValidateOptions<SmtpOptions>, SmtpOptionsValidator>();
builder.Services
.AddOptions<SmtpOptions>()
.BindConfiguration(SmtpOptions.SectionName)
.ValidateOnStart(); // triggers both DataAnnotations AND IValidateOptions
Configuration Audit on Startup
// Log safe configuration summary at startup โ helps debug deployment issues
public class ConfigurationAuditService(
IOptions<JwtSettings> jwt,
IOptions<SmtpOptions> smtp,
ILogger<ConfigurationAuditService> logger) : IHostedService
{
public Task StartAsync(CancellationToken ct)
{
logger.LogInformation(
"JWT: Issuer={Issuer}, Audience={Audience}, Expiry={Expiry}min, KeySet={KeySet}",
jwt.Value.Issuer,
jwt.Value.Audience,
jwt.Value.ExpiryMinutes,
!string.IsNullOrEmpty(jwt.Value.SecretKey)); // log PRESENCE, not VALUE
logger.LogInformation(
"SMTP: Host={Host}, Port={Port}, SSL={Ssl}, AuthConfigured={Auth}",
smtp.Value.Host,
smtp.Value.Port,
smtp.Value.UseSsl,
!string.IsNullOrEmpty(smtp.Value.Username)); // log PRESENCE, not VALUE
return Task.CompletedTask;
}
public Task StopAsync(CancellationToken ct) => Task.CompletedTask;
}
Common Mistakes
Mistake 1 โ Not using ValidateOnStart (late validation discovery)
โ Wrong โ validation only triggers when the first request accesses the misconfigured options, potentially hours after deployment.
โ
Correct โ always chain .ValidateOnStart() for any options that are required for the application to function.
Mistake 2 โ Logging secret values in the configuration audit
โ Wrong โ logging SecretKey={jwt.Value.SecretKey} exposes the JWT signing key in log files.
โ
Correct โ log only whether the value is set: KeyConfigured={!string.IsNullOrEmpty(jwt.Value.SecretKey)}.