Configuration Validation and Fail-Fast Startup

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
Note: Without 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.
Tip: For complex cross-property validation that DataAnnotations cannot express (e.g., “if UseSSL is true, then Port must be 465 or 587”), implement 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>().
Warning: Do not log full configuration values at startup โ€” they may contain secrets. Log only the fact that configuration loaded successfully, not the values. For debugging misconfiguration, log safe metadata: which keys are set (not their values), which configuration providers were loaded, and which sections were found. If you need to debug a specific option value, log only non-sensitive properties or mask sensitive ones: _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)}.

🧠 Test Yourself

You deploy a new version. The JWT SecretKey environment variable is missing. With ValidateOnStart(), what happens? Without it?