⚙️ Hands-On Tutorial

.NET 8 Essentials: Configuration, Secrets, and Environment Management (Done Right)

A developer commits a database password to the repository. It's a staging secret — it'll be fine. Six months later the key rotation audit finds it in the Git history of a project that has since been open-sourced. It's a story that plays out in real teams regularly, and the fix costs far more than the original care would have.

Configuration done right isn't complicated. It's a handful of patterns applied consistently: type your options, validate them at startup, keep secrets out of the repo, and give each environment exactly what it needs without manual file edits. This tutorial builds a Config Playground API that demonstrates every pattern with working code.

What You'll Build

A Config Playground API (tutorials/dotnet-8-essentials/ConfigPlaygroundApi/) demonstrating the full configuration stack used in production .NET 8 applications:

  • Strongly-typed options classes with DataAnnotations validation and startup failure on missing or invalid values
  • Full provider layer stackappsettings.json base → environment overrides → User Secrets for local dev → Key Vault-style provider for production
  • Safe local development workflow with dotnet user-secrets and a .gitignore audit checklist
  • Feature flags — a lightweight in-process pattern for per-environment toggles without an external service
  • IOptions / IOptionsSnapshot / IOptionsMonitor — when each variant is the right choice
  • Dev-only diagnostics endpoint — shows resolved config with secret values redacted, available only in Development environment
  • Named options for multi-instance config (multiple email providers, multiple queues)

Project Setup & File Layout

A standard Minimal API project. No extra NuGet packages required for the core configuration system — it's all in the framework. Azure Key Vault integration adds one package when you reach Section 6.

Terminal
dotnet new webapi -n ConfigPlaygroundApi -minimal
cd ConfigPlaygroundApi

# Init user secrets — creates a UserSecretsId in the .csproj
dotnet user-secrets init

# Key Vault provider (only needed for Section 6)
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
dotnet add package Azure.Identity

Configuration File Layout

Project Configuration Files
ConfigPlaygroundApi/
├── appsettings.json                  # base config — committed, no secrets
├── appsettings.Development.json      # dev defaults — committed, no secrets
├── appsettings.Staging.json          # staging overrides — committed, no secrets
├── appsettings.Production.json       # production structure — committed, placeholders only
├── Options/
│   ├── DatabaseOptions.cs
│   ├── EmailOptions.cs
│   ├── FeatureFlagOptions.cs
│   └── ApiClientOptions.cs
├── Validation/
│   └── OptionsValidator.cs
├── Features/
│   └── FeatureFlagService.cs
└── Program.cs

# .gitignore must include:
# secrets.json        (user secrets backup if you ever export them)
# .env                (if you use dotenv locally)
# appsettings.Local.json  (if you use a local override file)
appsettings.Production.json Should Contain Structure, Not Values

Commit appsettings.Production.json to the repo, but only with the key names and safe placeholder values — never real secrets. It documents what config keys exist in production so that whoever sets up the environment knows exactly what to provide. Real values arrive via environment variables or Key Vault at runtime, overriding the placeholders.

The Configuration Provider Stack

.NET 8's configuration system is a layered stack of providers. Each provider reads from a source and adds keys to a merged dictionary. Later providers win on key conflicts. Understanding the default order — and how to customise it — is the foundation of everything else in this tutorial.

Default Provider Order (Lowest → Highest Priority)

The Default Configuration Stack
// What WebApplication.CreateBuilder(args) sets up automatically:

// 1. appsettings.json              — base defaults, committed to repo
// 2. appsettings.{Environment}.json — environment override, committed
// 3. User Secrets                   — LOCAL DEV ONLY (when env = Development)
// 4. Environment Variables          — process-level, set by OS or container
// 5. Command-line arguments         — dotnet run --Jwt:Issuer=https://...

// Later in the list = higher priority = wins on conflicts

// To see what's registered and in what order:
var app = builder.Build();
var configRoot = app.Configuration as IConfigurationRoot;

// In Development only — dumps all providers and their loaded keys
foreach (var provider in configRoot?.Providers ?? [])
    Console.WriteLine(provider.ToString());

// Output example:
// JsonConfigurationProvider for 'appsettings.json' (Optional)
// JsonConfigurationProvider for 'appsettings.Development.json' (Optional)
// SecretsConfigurationProvider for secrets.json
// EnvironmentVariablesConfigurationProvider
// CommandLineConfigurationProvider

The appsettings.json Base File

appsettings.json — Structure Only, No Secrets
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Database": {
    "ConnectionString": "",
    "CommandTimeoutSeconds": 30,
    "MaxPoolSize": 20
  },
  "Email": {
    "SmtpHost": "",
    "SmtpPort": 587,
    "FromAddress": "[email protected]",
    "UseSsl": true,
    "ApiKey": ""
  },
  "ApiClients": {
    "PaymentService": {
      "BaseUrl": "",
      "TimeoutSeconds": 10,
      "ApiKey": ""
    }
  },
  "Features": {
    "NewCheckoutFlow": false,
    "AdvancedSearch":  false,
    "BetaDashboard":   false
  }
}
appsettings.Development.json — Safe Dev Defaults
{
  "Logging": {
    "LogLevel": {
      "Default": "Debug",
      "Microsoft.AspNetCore": "Information"
    }
  },
  "Database": {
    "ConnectionString": "Data Source=dev.db",
    "CommandTimeoutSeconds": 60
  },
  "Email": {
    "SmtpHost": "localhost",
    "SmtpPort": 1025,
    "UseSsl": false
  },
  "Features": {
    "NewCheckoutFlow": true,
    "BetaDashboard":   true
  }
}
Use a Local Mail Catcher in Development

Point Email:SmtpHost to localhost:1025 in appsettings.Development.json and run MailHog or Mailpit locally. Every email your app sends gets caught and displayed in a web UI — no real addresses, no SMTP credentials needed, and no risk of accidentally emailing real users from a dev environment. Zero secrets required in development for email.

Strongly-Typed Options & Startup Validation

Reading config with IConfiguration["Database:ConnectionString"] is string-based, unvalidated, and invisible to dependency injection. Strongly-typed options fix all three problems. And with ValidateOnStart(), a missing required value crashes the app at startup with a clear error — not silently, three hours into a production session.

Defining Options Classes

Options/DatabaseOptions.cs
using System.ComponentModel.DataAnnotations;

public class DatabaseOptions
{
    public const string SectionName = "Database";

    [Required(ErrorMessage = "Database:ConnectionString is required.")]
    [MinLength(10, ErrorMessage = "Database:ConnectionString looks too short to be valid.")]
    public string ConnectionString { get; init; } = "";

    [Range(5, 300)]
    public int CommandTimeoutSeconds { get; init; } = 30;

    [Range(1, 500)]
    public int MaxPoolSize { get; init; } = 20;
}

public class EmailOptions
{
    public const string SectionName = "Email";

    [Required]
    public string SmtpHost { get; init; } = "";

    [Range(1, 65535)]
    public int SmtpPort { get; init; } = 587;

    [Required, EmailAddress]
    public string FromAddress { get; init; } = "";

    public bool UseSsl { get; init; } = true;

    // ApiKey not Required here — some environments use SMTP auth, others use API key
    public string ApiKey { get; init; } = "";
}

public class ApiClientOptions
{
    public const string SectionName = "ApiClients";

    [Required]
    public string BaseUrl { get; init; } = "";

    [Range(1, 120)]
    public int TimeoutSeconds { get; init; } = 10;

    public string ApiKey { get; init; } = "";
}

Registering with Validation

Program.cs — Options Registration with Startup Validation
// Bind + validate DataAnnotations + fail at startup if invalid
builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart(); // ← key: fail immediately, not on first use

builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Shorthand extension that does the same thing in one line
builder.Services
    .AddOptionsWithValidateOnStart<FeatureFlagOptions>()
    .BindConfiguration(FeatureFlagOptions.SectionName);

Custom Cross-Field Validation with IValidateOptions

DataAnnotations validates individual properties. For rules that span multiple fields — "if UseSsl is true, SmtpPort must be 465 or 587" — implement IValidateOptions<T>.

Validation/EmailOptionsValidator.cs
public class EmailOptionsValidator : IValidateOptions<EmailOptions>
{
    public ValidateOptionsResult Validate(string? name, EmailOptions options)
    {
        var failures = new List<string>();

        // Cross-field rule: SSL ports
        if (options.UseSsl && options.SmtpPort is not (465 or 587))
            failures.Add(
                $"Email:SmtpPort must be 465 or 587 when UseSsl is true. Got: {options.SmtpPort}");

        // Cross-field rule: API key or SMTP must be configured
        if (string.IsNullOrEmpty(options.ApiKey) &&
            string.IsNullOrEmpty(options.SmtpHost))
            failures.Add(
                "Email configuration requires either SmtpHost or ApiKey to be set.");

        return failures.Count > 0
            ? ValidateOptionsResult.Fail(failures)
            : ValidateOptionsResult.Success;
    }
}

// Register the custom validator alongside the options
builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

// Register the cross-field validator
builder.Services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();
ValidateOnStart() Requires a Trigger

ValidateOnStart() works by registering a hosted service that reads the options during application startup — which forces the validation to run. If you only call ValidateDataAnnotations() without ValidateOnStart(), validation runs lazily on first use. In a web API that might mean a handler that's rarely called never triggers validation, and a misconfigured setting goes undetected until that specific code path is hit. Always pair them together.

Local Development: User Secrets Workflow

User Secrets stores sensitive local overrides in your OS user profile directory — outside the project folder, outside Git reach. The UserSecretsId in the .csproj links the project to its secrets store. Nothing else to configure.

Setting and Reading Secrets

Terminal — User Secrets Commands
# Set individual secrets — colon : works on all platforms
dotnet user-secrets set "Database:ConnectionString" "Data Source=dev.db;Password=localpass"
dotnet user-secrets set "Email:ApiKey" "sg.your-sendgrid-local-key"
dotnet user-secrets set "ApiClients:PaymentService:ApiKey" "pk_test_localkey"

# List all secrets currently set for this project
dotnet user-secrets list

# Remove a specific secret
dotnet user-secrets remove "Email:ApiKey"

# Clear ALL secrets for this project (useful when rotating keys)
dotnet user-secrets clear

# Where are they stored?
# Windows:  %APPDATA%\Microsoft\UserSecrets\{UserSecretsId}\secrets.json
# macOS:    ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json
# Linux:    ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json

The secrets.json File Structure

secrets.json — What It Looks Like on Disk
{
  "Database": {
    "ConnectionString": "Data Source=dev.db;Password=localpass123"
  },
  "Email": {
    "ApiKey": "sg.your-local-sendgrid-key"
  },
  "ApiClients": {
    "PaymentService": {
      "ApiKey": "pk_test_your_local_stripe_key"
    }
  }
}

Team Onboarding Script

New developers need to populate their secrets. A setup script (not committed with real values) documents exactly what secrets are required and provides safe placeholder prompts.

scripts/setup-secrets.sh
#!/bin/bash
# Run once after cloning: ./scripts/setup-secrets.sh
# This script documents required secrets without storing values.

cd ConfigPlaygroundApi

echo "Setting up local development secrets..."
echo ""
echo "Enter your local database connection string:"
read -r db_conn
dotnet user-secrets set "Database:ConnectionString" "$db_conn"

echo "Enter your local SendGrid API key (or press Enter to skip):"
read -r email_key
if [ -n "$email_key" ]; then
  dotnet user-secrets set "Email:ApiKey" "$email_key"
fi

echo "Enter your Stripe test key:"
read -r stripe_key
dotnet user-secrets set "ApiClients:PaymentService:ApiKey" "$stripe_key"

echo ""
echo "✓ Secrets configured. Run 'dotnet user-secrets list' to verify."
Check Your .gitignore Before Every New Project

User Secrets protect you from accidentally committing secrets in the default flow. But teams often add appsettings.Local.json, .env, or environment-specific override files for convenience — and forget to add them to .gitignore. Run git status before any commit that touches configuration. Better still, add a pre-commit hook via pre-commit that scans for common secret patterns before the commit lands.

Per-Environment Overrides & Env Var Mapping

The ASPNETCORE_ENVIRONMENT variable controls which appsettings.{Environment}.json file loads and which branches of code activate. Beyond that, every config key has an environment variable equivalent that overrides it at runtime.

Environment Variable Key Mapping

JSON Keys to Environment Variable Names
# JSON hierarchy separator ":" → "__" (double underscore) in env vars
# This is the universal cross-platform convention

# JSON key: Database:ConnectionString
export Database__ConnectionString="Server=prod-db;Database=app;..."

# JSON key: Email:SmtpPort
export Email__SmtpPort="465"

# JSON key: ApiClients:PaymentService:ApiKey
export ApiClients__PaymentService__ApiKey="pk_live_your_key"

# JSON key: Features:NewCheckoutFlow
export Features__NewCheckoutFlow="true"

# The environment itself
export ASPNETCORE_ENVIRONMENT="Production"

# Verify the mapping in code
// config["Database:ConnectionString"] reads the env var automatically
// No code changes needed — the provider handles the __ → : conversion

Staging-Specific Config File

appsettings.Staging.json
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "Database": {
    "CommandTimeoutSeconds": 45,
    "MaxPoolSize": 10
  },
  "Features": {
    "NewCheckoutFlow": true,
    "AdvancedSearch":  true,
    "BetaDashboard":   false
  }
}

Adding a Custom Environment

Program.cs — Custom Environment Detection
var app = builder.Build();

// Built-in environment checks
if (app.Environment.IsDevelopment())   { /* ... */ }
if (app.Environment.IsStaging())       { /* ... */ }
if (app.Environment.IsProduction())    { /* ... */ }

// Custom environment name — set ASPNETCORE_ENVIRONMENT=LoadTest
if (app.Environment.IsEnvironment("LoadTest"))
{
    // Disable external HTTP calls, use in-memory stubs
}

// Extension method for cleaner code
public static class EnvironmentExtensions
{
    public static bool IsLoadTest(this IWebHostEnvironment env) =>
        env.IsEnvironment("LoadTest");

    public static bool IsNotProduction(this IWebHostEnvironment env) =>
        !env.IsProduction();
}
Prefix Environment Variables to Avoid Collisions

In a containerised deployment, environment variables from the OS, the orchestrator, and multiple apps can collide. Use AddEnvironmentVariables("MYAPP_") to filter to a prefix — only variables starting with MYAPP_ are loaded. This means MYAPP_Database__ConnectionString maps to Database:ConnectionString. Set this up early: retrofitting a prefix convention to a running production system is harder than adopting it from day one.

Production Secrets: Key Vault-Style Provider

For production, secrets should live in a managed secret store — Azure Key Vault, AWS Secrets Manager, HashiCorp Vault — not in environment variables set manually on a server. The Key Vault provider loads secrets into the configuration system at startup, so the rest of your code reads them via IOptions<T> with zero changes.

Azure Key Vault Provider Setup

Program.cs — Azure Key Vault Configuration
using Azure.Identity;
using Azure.Extensions.AspNetCore.Configuration.Secrets;

var builder = WebApplication.CreateBuilder(args);

// Add Key Vault as a configuration provider — production only
if (!builder.Environment.IsDevelopment())
{
    var keyVaultUri = builder.Configuration["KeyVault:Uri"]
        ?? throw new InvalidOperationException(
            "KeyVault:Uri is required in non-Development environments. " +
            "Set it via KEYVAULT__URI environment variable.");

    // DefaultAzureCredential works with: Managed Identity, Azure CLI, VS, env vars
    // No credentials in code — authentication is handled by the Azure SDK
    builder.Configuration.AddAzureKeyVault(
        new Uri(keyVaultUri),
        new DefaultAzureCredential(),
        new AzureKeyVaultConfigurationOptions
        {
            // Optional: reload secrets on a schedule (for rotation)
            ReloadInterval = TimeSpan.FromMinutes(30)
        });
}

// Key Vault secret naming: hyphens in Key Vault = colons in .NET config
// Key Vault secret name: "Database--ConnectionString"
//                     → config key: "Database:ConnectionString"
// (Double dash -- is the Key Vault convention for hierarchy, not double underscore)

Fallback Chain: Local → Env Var → Key Vault

Custom Provider Order for Different Environments
// Explicit full provider chain with clear priority ordering
// Clear the defaults and rebuild to make precedence explicit
builder.Configuration.Sources.Clear();

builder.Configuration
    // 1. Base defaults (lowest priority)
    .AddJsonFile("appsettings.json",
        optional: false, reloadOnChange: false)

    // 2. Environment-specific overrides
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json",
        optional: true, reloadOnChange: false)

    // 3. User Secrets — Development only
    .AddUserSecrets<Program>(optional: true)

    // 4. Environment variables with MYAPP_ prefix
    .AddEnvironmentVariables("MYAPP_")

    // 5. Raw environment variables (for Kubernetes ConfigMaps/Secrets)
    .AddEnvironmentVariables()

    // 6. Command-line args (highest priority, useful for testing)
    .AddCommandLine(args);

// In production only, add Key Vault LAST so it has the highest priority
// (except command-line, which is intentionally higher for debugging)
if (!builder.Environment.IsDevelopment())
{
    var kvUri = builder.Configuration["KeyVault:Uri"];
    if (kvUri is not null)
        builder.Configuration.AddAzureKeyVault(
            new Uri(kvUri), new DefaultAzureCredential());
}
DefaultAzureCredential Tries Multiple Auth Methods

DefaultAzureCredential walks a chain of authentication methods: Managed Identity → Workload Identity → Azure CLI → Visual Studio → environment variables. In local development it picks up your az login session. In production it uses the VM or container's Managed Identity. This means the same code works everywhere with no credential hard-coding. The only thing you must ensure is that the Managed Identity has Key Vault Secrets User RBAC role on the vault — without it, startup will fail with a 403 and a clear error message.

IOptions vs IOptionsSnapshot vs IOptionsMonitor

Three interfaces, one underlying configuration, different update semantics. Choosing the wrong one won't break your app — but it will mean your config changes either never take effect without a restart, or take effect at the wrong granularity.

When to Use Each Options Interface
// ─── IOptions<T> — Singleton, reads once at startup ──────────────────────
// Use for: static configuration that never changes during the app lifetime
// JWT signing keys, database pool sizes, service URLs
public class JwtService(IOptions<JwtOptions> options)
{
    private readonly JwtOptions _opts = options.Value; // resolved once

    public string IssueToken(string userId)
    {
        // _opts will never change — safe to cache .Value
        var key = new SymmetricSecurityKey(Encoding.UTF8.GetBytes(_opts.SigningKey));
        // ...
    }
}

// ─── IOptionsSnapshot<T> — Scoped, re-reads per request ─────────────────
// Use for: config that may change between deploys (feature flags, thresholds)
// Only valid in scoped/transient services — NOT in singletons
public class FeatureGateMiddleware(IOptionsSnapshot<FeatureFlagOptions> opts)
{
    // opts.Value re-reads the current config at the start of each request
    // If appsettings.json changes on disk (reloadOnChange: true), next request sees it
    public bool IsEnabled(string featureName) =>
        opts.Value.IsEnabled(featureName);
}

// ─── IOptionsMonitor<T> — Singleton with change notification ────────────
// Use for: singletons that need to react to config changes in real time
// Background services, in-memory caches keyed on config, circuit breaker thresholds
public class EmailSender(IOptionsMonitor<EmailOptions> monitor, ILogger<EmailSender> logger)
{
    public EmailSender(IOptionsMonitor<EmailOptions> monitor, ILogger<EmailSender> logger)
    {
        // Subscribe to changes — fires when the config source updates
        monitor.OnChange(newOptions =>
        {
            logger.LogInformation(
                "Email config updated. New SmtpHost: {Host}", newOptions.SmtpHost);
            // Reinitialise SMTP client with new settings
        });
    }

    public EmailOptions Current => monitor.CurrentValue; // always up-to-date
}
IOptionsSnapshot Cannot Be Injected into Singletons

IOptionsSnapshot<T> is registered as scoped — it's created once per HTTP request scope. Injecting a scoped service into a singleton is a captive dependency and will throw a runtime exception in development (or silently use a stale snapshot in release mode if you bypass the scope validation). If you need a singleton that reflects config changes, use IOptionsMonitor<T> instead.

Named Options for Multi-Instance Config

Sometimes you need multiple instances of the same options type — two SMTP providers, three queue connections, several API client configurations. Named options let you register and resolve them by name using the same underlying type.

Named ApiClient Options — Registration & Use
// appsettings.json
{
  "ApiClients": {
    "PaymentService": { "BaseUrl": "https://pay.api.com", "TimeoutSeconds": 10 },
    "ShippingService": { "BaseUrl": "https://ship.api.com", "TimeoutSeconds": 20 },
    "InventoryService": { "BaseUrl": "https://inv.api.com", "TimeoutSeconds": 5  }
  }
}

// Program.cs — register each named instance
foreach (var clientName in new[] { "PaymentService", "ShippingService", "InventoryService" })
{
    builder.Services
        .AddOptions<ApiClientOptions>(clientName)   // named options
        .BindConfiguration($"ApiClients:{clientName}")
        .ValidateDataAnnotations()
        .ValidateOnStart();
}

// Consuming named options — inject IOptionsMonitor<T>
public class ApiClientFactory(IOptionsMonitor<ApiClientOptions> monitor)
{
    // Resolve by name
    public ApiClientOptions GetOptions(string clientName) =>
        monitor.Get(clientName); // returns the named instance

    public HttpClient CreateClient(string clientName)
    {
        var opts = monitor.Get(clientName);
        return new HttpClient
        {
            BaseAddress = new Uri(opts.BaseUrl),
            Timeout     = TimeSpan.FromSeconds(opts.TimeoutSeconds)
        };
    }
}

// Usage
var paymentOpts  = factory.GetOptions("PaymentService");
var shippingOpts = factory.GetOptions("ShippingService");

Lightweight Feature Flags

Microsoft.FeatureManagement is a full feature flag library. For teams that don't need targeting, percentages, or external flag services, a lightweight in-process pattern over strongly-typed options delivers most of the value with zero extra dependencies.

Feature Flag Options & Service

Options/FeatureFlagOptions.cs
public class FeatureFlagOptions
{
    public const string SectionName = "Features";

    public bool NewCheckoutFlow { get; init; }
    public bool AdvancedSearch  { get; init; }
    public bool BetaDashboard   { get; init; }

    // Dynamic lookup by name — useful for generic middleware
    public bool IsEnabled(string featureName) => featureName switch
    {
        nameof(NewCheckoutFlow) => NewCheckoutFlow,
        nameof(AdvancedSearch)  => AdvancedSearch,
        nameof(BetaDashboard)   => BetaDashboard,
        _                       => false  // unknown flags default to off
    };
}

public static class FeatureFlags
{
    // Strongly-typed constants for use in code — no magic strings
    public const string NewCheckoutFlow = nameof(FeatureFlagOptions.NewCheckoutFlow);
    public const string AdvancedSearch  = nameof(FeatureFlagOptions.AdvancedSearch);
    public const string BetaDashboard   = nameof(FeatureFlagOptions.BetaDashboard);
}
Features/FeatureFlagService.cs & Endpoint Usage
// Service wrapper — hides the IOptionsSnapshot injection detail from callsites
public class FeatureFlagService(IOptionsSnapshot<FeatureFlagOptions> options)
{
    public bool IsEnabled(string featureName) =>
        options.Value.IsEnabled(featureName);

    public bool NewCheckoutFlow => options.Value.NewCheckoutFlow;
    public bool AdvancedSearch  => options.Value.AdvancedSearch;
    public bool BetaDashboard   => options.Value.BetaDashboard;
}

builder.Services.AddScoped<FeatureFlagService>();

// Usage in endpoints — flag check before feature execution
app.MapGet("/search", (string q, FeatureFlagService flags) =>
{
    if (flags.AdvancedSearch)
    {
        // New implementation
        return TypedResults.Ok(AdvancedSearchService.Search(q));
    }

    // Legacy fallback
    return TypedResults.Ok(BasicSearchService.Search(q));
})
.WithTags("Search");

// Middleware — block access to beta routes for non-beta users
app.Use(async (ctx, next) =>
{
    if (ctx.Request.Path.StartsWithSegments("/beta"))
    {
        var flags = ctx.RequestServices.GetRequiredService<FeatureFlagService>();
        if (!flags.BetaDashboard)
        {
            ctx.Response.StatusCode = 404; // not found — don't reveal the route exists
            return;
        }
    }
    await next();
});
Feature Flag Naming Conventions Matter

Name flags after what they enable, not what they replace — NewCheckoutFlow not OldCheckoutFlowDisabled. Boolean negation in flag names causes double-negation logic errors (if (!flags.OldDisabled)) that are hard to reason about. Set a cleanup reminder when you ship a flag — every feature flag that ships to 100% should be removed within one sprint cycle. Accumulating permanent flags is technical debt that slows down every config change and onboarding.

Dev-Only Config Diagnostics Endpoint

"What value did that config key actually resolve to?" is a question every developer asks during environment setup. A diagnostics endpoint answers it instantly — showing the full resolved config, with secret values redacted, available only in Development.

Safe Config Dump Endpoint

Program.cs — Dev Diagnostics Endpoint
// ONLY register in Development — this endpoint must never reach production
if (app.Environment.IsDevelopment())
{
    app.MapGet("/dev/config", (IConfiguration config, IWebHostEnvironment env) =>
    {
        // Words that indicate a value should be redacted
        var sensitiveKeywords = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        {
            "secret", "key", "password", "token",
            "connectionstring", "apikey", "credential"
        };

        bool IsSensitive(string key) =>
            sensitiveKeywords.Any(kw =>
                key.Contains(kw, StringComparison.OrdinalIgnoreCase));

        // Walk all config entries and redact sensitive values
        var entries = config.AsEnumerable()
            .OrderBy(e => e.Key)
            .Select(e => new
            {
                key   = e.Key,
                value = e.Value is null ? null
                      : IsSensitive(e.Key) ? "[REDACTED]"
                      : e.Value
            });

        return TypedResults.Ok(new
        {
            environment     = env.EnvironmentName,
            applicationName = env.ApplicationName,
            timestamp       = DateTime.UtcNow,
            configEntries   = entries
        });
    })
    .WithName("DevConfigDiagnostics")
    .WithTags("Dev");

    // Resolved options diagnostics — shows the typed object, not raw keys
    app.MapGet("/dev/options", (
        IOptions<DatabaseOptions>   dbOpts,
        IOptions<EmailOptions>      emailOpts,
        IOptions<FeatureFlagOptions> featureOpts) =>
    {
        return TypedResults.Ok(new
        {
            database = new
            {
                dbOpts.Value.CommandTimeoutSeconds,
                dbOpts.Value.MaxPoolSize,
                connectionString = "[REDACTED]"  // never return this
            },
            email = new
            {
                emailOpts.Value.SmtpHost,
                emailOpts.Value.SmtpPort,
                emailOpts.Value.FromAddress,
                emailOpts.Value.UseSsl,
                apiKey = "[REDACTED]"
            },
            features = featureOpts.Value
        });
    })
    .WithTags("Dev");
}

Provider Source Diagnostics

Which Provider Supplied Each Value?
// Show which config provider is supplying each key — useful during onboarding
if (app.Environment.IsDevelopment())
{
    app.MapGet("/dev/config/providers", (IConfiguration config) =>
    {
        if (config is not IConfigurationRoot root)
            return Results.Problem("Configuration is not IConfigurationRoot");

        // GetDebugView() returns a human-readable provider tree
        // Available in .NET 6+ as an extension on IConfigurationRoot
        var debugView = root.GetDebugView();

        return TypedResults.Ok(new { providerTree = debugView });
    })
    .WithTags("Dev");
}

// Example output from GetDebugView():
// JsonConfigurationProvider for 'appsettings.json':
//   Database:CommandTimeoutSeconds=30
//   Database:MaxPoolSize=20
//
// JsonConfigurationProvider for 'appsettings.Development.json':
//   Database:CommandTimeoutSeconds=60       <-- overrides base
//   Features:NewCheckoutFlow=true
//
// SecretsConfigurationProvider:
//   Database:ConnectionString=[REDACTED]    <-- wins
Gate Diagnostics by Environment, Not by Authorization Alone

An authorization check on the diagnostics endpoint is good defence-in-depth. But the primary gate must be the environment check: if (app.Environment.IsDevelopment()). Authorization misconfiguration (a missing policy, an incorrect role claim) is far more likely than environment detection failing. If the diagnostics endpoint is registered in production, even a momentary auth misconfiguration could expose your full resolved config. Register it only in Development — full stop.

End-to-End: Complete Program.cs

All patterns assembled — provider stack, strongly-typed options, startup validation, Key Vault, feature flags, and dev diagnostics in a single production-ready Program.cs.

Program.cs — Complete Config Playground API
using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Identity;
using Microsoft.Extensions.Options;

var builder = WebApplication.CreateBuilder(args);

// ─── Configuration Provider Stack ─────────────────────────────────────────
builder.Configuration.Sources.Clear();

builder.Configuration
    .AddJsonFile("appsettings.json",                                optional: false)
    .AddJsonFile($"appsettings.{builder.Environment.EnvironmentName}.json", optional: true)
    .AddUserSecrets<Program>(optional: true)          // active in Development
    .AddEnvironmentVariables("CFGAPI_")               // prefixed env vars
    .AddEnvironmentVariables()                        // unprefixed (Kubernetes secrets/configmaps)
    .AddCommandLine(args);

// Add Azure Key Vault for non-Development environments
if (!builder.Environment.IsDevelopment())
{
    var kvUri = builder.Configuration["KeyVault:Uri"];
    if (kvUri is not null)
        builder.Configuration.AddAzureKeyVault(
            new Uri(kvUri),
            new DefaultAzureCredential(),
            new AzureKeyVaultConfigurationOptions { ReloadInterval = TimeSpan.FromMinutes(30) });
}

// ─── Strongly-Typed Options with Startup Validation ───────────────────────
builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();

builder.Services
    .AddOptions<EmailOptions>()
    .BindConfiguration(EmailOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();
builder.Services.AddSingleton<IValidateOptions<EmailOptions>, EmailOptionsValidator>();

builder.Services
    .AddOptionsWithValidateOnStart<FeatureFlagOptions>()
    .BindConfiguration(FeatureFlagOptions.SectionName);

// Named ApiClient options
foreach (var name in new[] { "PaymentService", "ShippingService", "InventoryService" })
{
    builder.Services
        .AddOptions<ApiClientOptions>(name)
        .BindConfiguration($"ApiClients:{name}")
        .ValidateDataAnnotations()
        .ValidateOnStart();
}

// ─── Feature Flags ────────────────────────────────────────────────────────
builder.Services.AddScoped<FeatureFlagService>();

// ─── HttpClient with typed options ───────────────────────────────────────
builder.Services.AddHttpClient("PaymentService", (sp, client) =>
{
    var opts = sp.GetRequiredService<IOptionsMonitor<ApiClientOptions>>()
                 .Get("PaymentService");
    client.BaseAddress = new Uri(opts.BaseUrl);
    client.Timeout     = TimeSpan.FromSeconds(opts.TimeoutSeconds);
});

var app = builder.Build();

// ─── Middleware ───────────────────────────────────────────────────────────
app.UseHttpsRedirection();
app.Use(async (ctx, next) =>
{
    ctx.Response.Headers["X-Content-Type-Options"] = "nosniff";
    ctx.Response.Headers["X-Frame-Options"]         = "DENY";
    await next();
});

// ─── API Endpoints ────────────────────────────────────────────────────────
app.MapGet("/", (IOptions<DatabaseOptions> db, FeatureFlagService flags) =>
    TypedResults.Ok(new
    {
        message            = "Config Playground API",
        dbPoolSize         = db.Value.MaxPoolSize,
        newCheckoutEnabled = flags.NewCheckoutFlow
    }));

app.MapGet("/search", (string? q, FeatureFlagService flags) =>
{
    var mode = flags.AdvancedSearch ? "advanced" : "basic";
    return TypedResults.Ok(new { query = q, mode, results = Array.Empty<string>() });
});

// ─── Dev-Only Diagnostics (never exposed in production) ──────────────────
if (app.Environment.IsDevelopment())
{
    var sensitiveKws = new HashSet<string>(StringComparer.OrdinalIgnoreCase)
        { "secret", "key", "password", "token", "connectionstring", "apikey" };

    app.MapGet("/dev/config", (IConfiguration cfg, IWebHostEnvironment env) =>
        TypedResults.Ok(new
        {
            environment = env.EnvironmentName,
            timestamp   = DateTime.UtcNow,
            entries     = cfg.AsEnumerable()
                             .OrderBy(e => e.Key)
                             .Select(e => new
                             {
                                 key   = e.Key,
                                 value = e.Value is null ? null
                                       : sensitiveKws.Any(kw => e.Key.Contains(kw, StringComparison.OrdinalIgnoreCase))
                                         ? "[REDACTED]" : e.Value
                             })
        }));

    app.MapGet("/dev/options", (
        IOptions<DatabaseOptions>    db,
        IOptions<EmailOptions>       email,
        IOptions<FeatureFlagOptions> features) =>
        TypedResults.Ok(new
        {
            database = new { db.Value.CommandTimeoutSeconds, db.Value.MaxPoolSize },
            email    = new { email.Value.SmtpHost, email.Value.SmtpPort,
                             email.Value.FromAddress, email.Value.UseSsl },
            features = features.Value
        }));
}

app.Run();

Options Validation Integration Test

Testing Startup Validation
public class ConfigValidationTests
{
    [Fact]
    public void DatabaseOptions_MissingConnectionString_FailsValidation()
    {
        var opts = new DatabaseOptions(); // ConnectionString defaults to ""
        var ctx  = new ValidationContext(opts);
        var results = new List<ValidationResult>();

        var isValid = Validator.TryValidateObject(opts, ctx, results, validateAllProperties: true);

        Assert.False(isValid);
        Assert.Contains(results, r =>
            r.MemberNames.Contains(nameof(DatabaseOptions.ConnectionString)));
    }

    [Fact]
    public void EmailOptions_SslWithWrongPort_FailsCustomValidation()
    {
        var opts = new EmailOptions
        {
            SmtpHost    = "smtp.example.com",
            SmtpPort    = 25,   // wrong port for SSL
            FromAddress = "[email protected]",
            UseSsl      = true
        };

        var validator = new EmailOptionsValidator();
        var result    = validator.Validate(null, opts);

        Assert.True(result.Failed);
        Assert.Contains("465 or 587", result.FailureMessage);
    }

    [Fact]
    public void FeatureFlagOptions_UnknownFlagName_ReturnsFalse()
    {
        var opts = new FeatureFlagOptions
        {
            NewCheckoutFlow = true,
            AdvancedSearch  = true
        };

        // Unknown flags default to off — no exception
        Assert.False(opts.IsEnabled("NonExistentFlag"));
        Assert.True(opts.IsEnabled(nameof(FeatureFlagOptions.NewCheckoutFlow)));
    }
}

Frequently Asked Questions

What is the correct order of configuration providers in .NET 8?

Later providers override earlier ones. The default order is: appsettings.jsonappsettings.{Environment}.json → User Secrets (Development only) → Environment Variables → Command-line arguments. This means an environment variable always wins over appsettings.json, and command-line args win over everything. In production, environment variables or a Key Vault provider should supply secrets, overriding placeholder values in committed config files.

Why should I use IOptions<T> instead of IConfiguration directly?

IConfiguration gives you stringly-typed key lookups with no compile-time safety and no validation. IOptions<T> binds configuration to a typed class, validates at startup with IValidateOptions, and participates in dependency injection cleanly. A missing or invalid setting with ValidateOnStart() fails the app immediately at launch — not silently, hours into a production session when an obscure code path finally runs.

Is it safe to commit appsettings.Development.json to source control?

Yes, as long as it contains no real secrets. appsettings.Development.json should hold non-sensitive defaults that let developers run the app locally without extra setup — localhost connection strings, feature flag defaults, relaxed log levels. Real secrets belong in User Secrets for local development, stored outside the repo in the OS user profile directory and never committed.

How do environment variables map to nested configuration keys in .NET?

Use double underscore (__) as the hierarchy separator. The key ConnectionStrings:DefaultConnection in appsettings.json maps to the environment variable ConnectionStrings__DefaultConnection. Single colons are not valid in environment variable names on most platforms. Double underscore is the universal cross-platform convention and should always be used.

What is the difference between IOptions, IOptionsSnapshot, and IOptionsMonitor?

IOptions<T> is a singleton — reads once at startup, never changes. IOptionsSnapshot<T> is scoped — re-reads at the start of each HTTP request, cannot be injected into singletons. IOptionsMonitor<T> is a singleton with a change callback — updates in real time when configuration changes. Use IOptions for static config, IOptionsSnapshot for request-scoped flags, and IOptionsMonitor for background services or anything that must react to live config changes.

How do I prevent the diagnostics endpoint from leaking config in production?

Three layers: (1) Register it only inside if (app.Environment.IsDevelopment()) — this is the primary gate. (2) Redact values whose key contains "secret", "key", "password", or "token" before rendering. (3) Add an authorization check as defence-in-depth even in Development. Never expose raw IConfiguration to any HTTP endpoint in a non-Development environment.

Back to Tutorials