← Back to list

Managing Configuration and Options in ASP.NET Core 10

From appsettings.json to Azure Key Vault and App Configuration. This guide covers the full Options pattern, validation, reloading, secrets…

Compile & Conquer · 2026-05-28 16:10 · 3 claps · 20.8 min read
#dotnet-core #configuration-file #azure #secrets #dot-net-developers
Open on Medium ↗
Wiki topics: ☁️ · DevOps & Cloud

Managing Configuration and Options in ASP.NET Core 10

From appsettings.json to Azure Key Vault and App Configuration. This guide covers the full Options pattern, validation, reloading, secrets, the cloud, source generators, AOT, and custom providers step by step.

Configuration looks simple in ASP.NET Core.

You read values from appsettings.json, inject them into services, and move on.

But real production systems are very different.

Problems usually start when:

  • a feature flag changes but the app does not reload it
  • secrets accidentally enter source control
  • containers use wrong environment variables
  • staging and production behave differently
  • a bad connection string crashes the app after deployment
  • one microservice uses outdated settings
  • Kubernetes overrides values unexpectedly
  • configuration reload causes runtime issues

These are not “small config bugs.”

They are architecture problems.

Part 1 Foundations: where configuration comes from

In this part, you will see how configuration is loaded, which providers feed it, how they layer, how to read values, and how to see the final result.

The mental model: a layered key-value store

Here is the most important idea. IConfiguration is not a file reader. It is a flat dictionary. The keys are strings and the values are strings. This dictionary is built from a list of configuration providers. Each provider adds key-value pairs. If two providers set the same key, the later one wins.

Hierarchical keys flatten

JSON looks nested, but configuration is flat. Take this JSON:

{
  "Database": {
    "ConnectionString": "Server=...;Database=app;",
    "CommandTimeoutSeconds": 30
  }
}

It becomes two flat keys:

Database:ConnectionString = Server=…;Database=app; Database:CommandTimeoutSeconds = 30

The : is the separator between levels. Some systems do not allow : in environment variable names. On those, you use two underscores __ instead, and ASP.NET Core turns it back into :. So Database__ConnectionString maps to Database:ConnectionString.

Arrays flatten by index:

{ “AllowedHosts”: [ “a.com”, “b.com” ] }

becomes AllowedHosts:0 = a.com and AllowedHosts:1 = b.com.

Precedence

WebApplication.CreateBuilder(args) loads a default list of providers, in this order:

  1. appsettings.json
  2. appsettings.{Environment}.json (e.g. appsettings.Production.json)
  3. User Secrets (Development only)
  4. Environment variables
  5. Command-line arguments

The rule is simple: if the same key is set by more than one provider, the last one in the list wins.

The built-in providers

Configuration is built from the providers above. Here is what each one is for, with an example.

**appsettings.json** is the base file. It is created by default. Put your normal, non-secret default values here.

{
  "ShopSettings": { "Title": "My Shop", "CurrencySymbol": "$" }
}

**appsettings.{Environment}.json** overrides the base file for one environment. It only needs the values that are different. For example, in appsettings.Development.json you might change just the title:

{
  "ShopSettings": { "Title": "My Shop (Dev)" }
}

Everything you do not repeat here still comes from appsettings.json. So in development, the title is "My Shop (Dev)" but the currency symbol is still "$".

Environment variables let you set values from outside the app. This is the normal way to override values in containers and CI/CD. Nested keys use the __ separator. For example, the variable below sets the same key as the JSON above:

ShopSettings__Title=My Shop (from env var)

During local development, the easiest place to set them is launchSettings.json. Each launch profile can define its own variables. That file is for local use only — it is not published with the app. You can also set machine-level variables, or set them in a terminal for one session. You can also load only variables with a certain prefix:

{
  "profiles": {
    "MyApp": {
      "commandName": "Project",
      "environmentVariables": {
        "ASPNETCORE_ENVIRONMENT": "Development",
        "ShopSettings__Title": "My Shop (from launchSettings)"
      }
    }
  }
}

When you run with the “MyApp” profile, ShopSettings__Title becomes the key ShopSettings:Title, and its value wins over the JSON files. You can also set the same variables at the machine level, or in a terminal for one session.

Loading only variables with a prefix. By default, all environment variables are loaded. If you want to load only the ones that belong to your app, add a prefix. The prefix is stripped after loading:

builder.Configuration.AddEnvironmentVariables(prefix: "MYAPP_");

With this, an environment variable named MYAPP_ShopSettings__Title becomes the key ShopSettings:Title. A variable without the MYAPP_ prefix is ignored. This is handy on a shared server, so unrelated system variables do not leak into your configuration.

One variable is special: ASPNETCORE_ENVIRONMENT. It uses a single underscore. It sets the environment name (Development, Staging, Production, or any name you pick) and decides which appsettings.{Environment}.json file loads. If it is not set, ASP.NET Core defaults to Production.

Reading values : and the quiet-failure trap

Once the providers have loaded, you read values from IConfiguration. There are a few ways:

// String indexer: quickest, but always returns string? (or null).
var url = builder.Configuration["PaymentApi:BaseUrl"];
// String indexer: quickest, but always returns string? (or null).
var url = builder.Configuration["PaymentApi:BaseUrl"];

// GetValue<T>: reads and converts to the type you ask for. You can give a default.
var timeout = builder.Configuration.GetValue<int>("PaymentApi:TimeoutSeconds", 30);

// GetSection: get a group of related values, then read from inside it.
var section = builder.Configuration.GetSection("PaymentApi");
var baseUrl = section.GetValue<string>("BaseUrl");

// GetConnectionString: shorthand for the "ConnectionStrings" section.
var conn = builder.Configuration.GetConnectionString("Default");

Now the important part. None of these throw when a key or section is missing. This is the quiet-failure trap:

  • The string indexer returns null for a missing key.
  • GetValue<T> returns the default for the type: null for a string, 0 for an int, false for a bool.
  • GetSection for a missing section returns an empty section, not an error. Reading from it then gives you the same quiet null or default values.

So a misconfigured app can start and keep running with the wrong values. It does not fail early with a clear error. This is exactly why validation (Part 2) matters.

If you want a missing section to fail loudly instead, use GetRequiredSection. It throws when the section is not there:

var section = builder.Configuration.GetRequiredSection("PaymentApi");

Inspecting the final configuration (locally)

Because values come from many providers, the hard question is often: where did this value come from? Two tools help.

// 1. GetDebugView: shows every key, its final value, AND which provider supplied it.
var root = (IConfigurationRoot)builder.Configuration;
Console.WriteLine(root.GetDebugView());
// 2. AsEnumerable: a flat list of the final keys and values (no source info).
foreach (var pair in builder.Configuration.AsEnumerable())
    Console.WriteLine($"{pair.Key} = {pair.Value}");

Use GetDebugView when you need to know which provider won for a key. Use AsEnumerable when you just need to see the final keys and values.

A small but useful difference while we are here:

  • IConfiguration is the read-only view the rest of your app uses. This is what you inject and depend on.
  • IConfigurationRoot sits one level lower. It also knows the provider list and supports reload. You cast to it for diagnostics like GetDebugView. Normal app code does not need it.

One warning: these views can show secrets. Use them only for local troubleshooting. Never leave them on in production.

Part 2 : Consuming configuration: the Options pattern

You now know where values come from. The next question is how to use them in your code cleanly. In this part: typed options, the binding ways, the three interfaces (which control reload), named options, conditional setup, and validation

Why not just read strings everywhere?

You can read values directly with string keys, like _config["Database:CommandTimeoutSeconds"]. This works, but it is weak. There is no type safety. There is no validation. The key names are easy to misspell. Your code is tied to those strings. The Options pattern fixes this. It binds a configuration section to a normal C# class. You then inject that class like any other service.

First, define a class. The property names must match the configuration keys:

public sealed class DatabaseOptions
{
    public const string SectionName = "Database";
    public string ConnectionString { get; set; } = string.Empty;
    public int CommandTimeoutSeconds { get; set; } = 30;
}

The cleanest modern way to register it is the AddOptions<T> builder with BindConfiguration:

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName);

Every way to bind, from simple to complete

There are five steps here, from simplest to most complete. They work at different levels. Keep one difference in mind:

  • **GetSection("X") is a configuration operation.** It returns a slice of the config tree. It knows nothing about dependency injection (DI).
  • **AddOptions<T>() is a DI registration.** It sets up the typed-options pipeline inside the DI container.

You often use them together. Here is the path from a throwaway read to a production setup.

Step 0 — raw indexer. Read values with string keys. Fine for a quick test. Bad for real code: no type safety, no validation, string keys everywhere.

var timeout = builder.Configuration["Database:CommandTimeoutSeconds"]; // string!

Step 1 — GetSection().Get<T>() (one-shot, no DI). Binds a section into a new object and returns it. The container is not involved. That is why this is the right tool at startup, before the container exists. You use it to read the values you need in order to register other providers (like a Key Vault name).

var kv = builder.Configuration.GetSection("KeyVault").Get<KeyVaultOptions>();

Step 2 — Configure<T>(GetSection(...)) (minimal DI). Registers the options in the container and binds the section. The shortest way to get injectable typed options. The limit: it returns void, so you cannot chain validation onto it.

builder.Services.Configure<DatabaseOptions>(
    builder.Configuration.GetSection(DatabaseOptions.SectionName));

Step 3 — AddOptions<T>().BindConfiguration(...) (the builder). Same binding as Step 2, but it returns a builder you can chain (example step 4 you can add validation)

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName);

Step 4 — add validation. The main reason to use the builder. Chain validation and ValidateOnStart(). Now a bad config crashes the app at startup, not at the first request (details later in this part).

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

Step 5 — pro touches. Named options, PostConfigure(...) to change values after binding, and validators. Only the AddOptions<T> builder gives you these.

builder.Services
    .AddOptions<DatabaseOptions>("Reporting")          // named instance
    .BindConfiguration("ReportingDatabase")
    .PostConfigure(o => o.CommandTimeoutSeconds *= 2)  // tweak after binding
    .ValidateOnStart();

Here is the quick comparison:

The decision: read now, or register?

Use GetSection().Get<T>() only for values you need before the dependency injection container is built. For anything you will inject, use AddOptions<T>().BindConfiguration(...).ValidateOnStart().

AddOptions<T>().BindConfiguration(...) is the default and the recommended way. It is not "one of two equal options." For anything your app consumes, it wins, because it gives you four things Get<T>() cannot:

  • Clean injection — classes ask for IOptions<T>, not raw config.
  • Lifetimes and reloadIOptionsSnapshot / IOptionsMonitor pick up changes without a restart.
  • Validation at startupValidateOnStart() fails a bad config at boot.
  • Named options and post-configure — multiple instances, and tweaks after binding.

So the starting assumption is always: use AddOptions<T>.

The patch (the one exception)

There is exactly one situation where AddOptions<T> can't help you, and it's a timing problem, not a quality problem.

AddOptions<T> only registers a recipe. The value it describes is produced later, after builder.Build(), when something injects it. But some work happens before Build() while you are still assembling the app: adding a provider like Key Vault, branching on the environment, configuring CORS or a DbContext. That work needs a real value on that line, right now. A recipe-for-later is useless there.

Here’s where it sits in the startup flow:

Case A : you are adding a configuration provider. The value lets you plug a new source into IConfiguration. This must run before Build(), because the provider list locks then

using Azure.Identity;
var builder = WebApplication.CreateBuilder(args);
// =======================================================
// PHASE 1 - BOOTSTRAP / INFRASTRUCTURE SETUP
// =======================================================
//
// At this stage:
//
// - builder.Configuration already exists
// - appsettings.json already loaded
// - environment variables already loaded
//
// BUT:
//
// - DI container is NOT built yet
// - services are NOT created yet
// - IOptions<T> injection does NOT exist yet
// So bootstrap reads use:
// - Get<T>()
// - GetValue<T>()
// - direct IConfiguration access
// =======================================================
// Read small bootstrap config needed RIGHT NOW
var vault =  builder.Configuration["KeyVault:Name"];
// We need the vault name NOW
// because AddAzureKeyVault requires the vault URI immediately
var kvUri =
    $"https://{vault!.Name}.vault.azure.net/";
// Add Azure Key Vault provider
builder.Configuration.AddAzureKeyVault(
    new Uri(kvUri),
    new DefaultAzureCredential());
// =======================================================
// PHASE 2 - SERVICE REGISTRATION
// =======================================================
//
// Key Vault is now part of IConfiguration.
// Configuration now contains values from:
// - appsettings.json
// - environment variables
// - Azure Key Vault
// NOW we register typed runtime options.
// =======================================================
//The DI container does NOT exist yet. Nothing has been created.
builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration("Database")
    .ValidateOnStart();
// Example runtime service
builder.Services.AddScoped<OrderService>();
// =======================================================
// PHASE 3 - BUILD THE DI CONTAINER
// =======================================================
// THIS is where the DI container is actually created.
// Before this line:
// - registrations only
// - no service instances exist
// After this line:
// - container exists
// - services can be resolved
// - IOptions<T> works
// =======================================================
var app = builder.Build();
// =======================================================
// PHASE 4 - RUNTIME
// =======================================================
//
// From here:
// - ASP.NET Core can inject services
// - controllers can be created
// - IOptions<T> works
// - requests can be handled
// =======================================================
app.MapGet("/", () => "Application Running");
app.Run();

Case B — config decides which services get registered. The value changes the shape of the app. Registration happens once at startup, so the value is needed then.

if (builder.Configuration.GetValue<bool>("Cache:UseRedis"))
    builder.Services.AddStackExchangeRedisCache(o =>
        o.Configuration = builder.Configuration["Cache:Redis:ConnectionString"]);
else
    builder.Services.AddMemoryCache();

Case C — configuring a framework’s own options at registration. This looks like a bootstrap read, but it is really the options pattern in disguise. The AddXxx(...) call reads config to fill the framework's options object.

JWT authentication:

builder.Services.AddAuthentication().AddJwtBearer(options =>
{
    options.Authority = builder.Configuration["Auth:Authority"];
    options.Audience  = builder.Configuration["Auth:Audience"];
});

OpenTelemetry exporter

builder.Services.AddOpenTelemetry().WithTracing(t => t.AddOtlpExporter(o =>
    o.Endpoint = new Uri(builder.Configuration["Otel:Endpoint"]!)));

Serilog — read sink/level config to build the logger early:

builder.Host.UseSerilog((ctx, cfg) => cfg.ReadFrom.Configuration(ctx.Configuration));

apart from above example Why go through DI at all?

DI is not required to read configuration. GetSection().Get<T>() already gives you a typed object with no container. So why do the other ways use DI?

Here is the simple version. Without DI, configuration is just a plain object. DI adds four things on top:

  • Clean injection. A class asks for IOptions<DatabaseOptions> in its constructor. It does not reach into a global, and it does not know where the value came from.
  • Lifetimes. The container decides when the object is created — once, per request, or kept live.
  • Reload. Because the container can re-create the object, a new value can flow in without restarting the app.
  • Validation and more. Startup validation, named options, and post-configure are all services the container runs.

So binding is the easy part. DI is what turns a bound object into a clean, injectable, reloadable dependency.

// Without DI — hidden dependency on a global; hard to test:
public sealed class OrderService
{
    private readonly DatabaseOptions _options;
    public OrderService()
        => _options = GlobalConfig.Instance
            .GetSection("Database").Get<DatabaseOptions>()!;
}

With DI, it just asks for what it needs:

// With DI — clear dependency, easy to test:
public sealed class OrderService(IOptions<DatabaseOptions> options)
{
    private readonly DatabaseOptions _options = options.Value;
}

The three interfaces — and reload behavior

There are three ways to inject options. They differ in lifetime and in whether they pick up changes (reload):

Here is the classic bug. A team binds a feature flag with IOptions<T>, switches it on in production, and nothing happens until they restart. The reason: IOptions<T> is bound only once. The fix is IOptionsMonitor<T> (for singletons) or IOptionsSnapshot<T> (per request). Use the reloading versions only when you need reload. For a value that is fixed for the run, plain IOptions<T> is correct and cheapest.

Watch out: You cannot inject IOptionsSnapshot<T> (scoped) into a singleton. A singleton lives longer than a request, so the lifetimes do not match. Singletons must use IOptionsMonitor<T>.

Named options

When you need more than one configuration of the same type, use named options. For example, two outbound API clients:

builder.Services.Configure<ApiClientOptions>(
    "Billing", builder.Configuration.GetSection("BillingApi"));
builder.Services.Configure<ApiClientOptions>(
    "Shipping", builder.Configuration.GetSection("ShippingApi"));

// Resolve a specific one:
public sealed class BillingClient(IOptionsSnapshot<ApiClientOptions> options)
{
    private readonly ApiClientOptions _opts = options.Get("Billing");
}

Conditional and environment-aware configuration

Sometimes the setup is not the same everywhere, and sometimes a value must be computed instead of read straight from a section. There are two kinds of “conditional,” and they use different tools.

Kind 1 : load different providers per environment. Locally you use User Secrets; in the cloud you use Key Vault. Branch on builder.Environment:

if (builder.Environment.IsDevelopment())
{
    builder.Configuration.AddJsonFile("appsettings.Local.json", optional: true);
}
else
{
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{builder.Configuration["KeyVault:Name"]}.vault.azure.net/"),
        new DefaultAzureCredential());
}

Kind 2 — compute a value from another service. A plain bind cannot do this. The OptionsBuilder.Configure(...) overloads let you use injected dependencies:

builder.Services
    .AddOptions<CacheOptions>()
    .BindConfiguration("Cache")
    .Configure<IOptions<DatabaseOptions>>((cache, db) =>
    {
        cache.TtlSeconds = Math.Min(cache.TtlSeconds, db.Value.CommandTimeoutSeconds - 5);
    });

For more complex logic, move it into an IConfigureOptions<T> class. It can take several services and is easy to unit-test:

public sealed class ConfigureCacheOptions(IHostEnvironment env)
    : IConfigureOptions<CacheOptions>
{
    public void Configure(CacheOptions options)
    {
        if (!env.IsProduction())
            options.Enabled = false;
    }
}

builder.Services.AddSingleton<IConfigureOptions<CacheOptions>, ConfigureCacheOptions>();

Use PostConfigure(...) to change values after every binding and configure step (above)

Validation : fail fast at startup

An app that starts with bad config and fails later is worse than one that refuses to start. Validation lets you reject bad config at startup.

Data annotations are the easiest. You add attributes to the properties:

public sealed class DatabaseOptions
{
    public const string SectionName = "Database";
    [Required, MinLength(1)]
    public string ConnectionString { get; set; } = string.Empty;
    [Range(1, 300)]
    public int CommandTimeoutSeconds { get; set; } = 30;
}

Then turn on validation and ValidateOnStart():

builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateDataAnnotations()
    .ValidateOnStart();   // checks at startup, not at first request

ValidateOnStart() is the key line. Without it, validation runs only when the options are first used which might be days later, on one rarely-used page. With it, a bad config fails the deploy right away. Always add it.

For rules across fields (like “field A must be greater than field B”), data annotations are not enough. Implement IValidateOptions<T>:

public sealed class DatabaseOptionsValidator : IValidateOptions<DatabaseOptions>
{
    public ValidateOptionsResult Validate(string? name, DatabaseOptions options)
    {
        if (options.CommandTimeoutSeconds < 1)
            return ValidateOptionsResult.Fail("CommandTimeoutSeconds must be positive.");

            return ValidateOptionsResult.Success;
    }
}
builder.Services.AddSingleton<
    IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();

There is also a faster, compile-time way to write validators : the source generator. It is covered in Part 4. A good habit: bind a section you depend on with GetRequiredSection, so a missing section fails right away instead of binding to defaults.

Part 3 : Configuration in the cloud

Local files and User Secrets are fine to start. But once the app runs in several environments and several instances, you want one central place for settings, real secret storage, and the ability to change a value without a redeploy. In this part: Key Vault for secrets, App Configuration for central settings, feature flags, and Managed Identity tying it together.

One rule runs through everything here: no secret that grants access lives in your build artifact or your pipeline. The app’s identity is its credential.

Secrets vs settings

Not every value carries the same risk. A page title or a timeout is just a setting. An API key, a password, or a connection string is a secret — it unlocks access. Both live in the configuration system, but you store them differently:

  • Settings (non-secret) → appsettings.json, App Configuration.
  • Secrets → User Secrets locally, Key Vault in the cloud.

A secret in development is still a secret. Keep it out of normal config files everywhere.

Azure Key Vault for secrets

Add the packages Azure.Extensions.AspNetCore.Configuration.Secrets and Azure.Identity. Then register Key Vault as a provider:

var keyVaultName = builder.Configuration["KeyVault:Name"];
if (!string.IsNullOrWhiteSpace(keyVaultName))
{
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{keyVaultName}.vault.azure.net/"),
        new DefaultAzureCredential());
}

Two things trip people up:

  • Secret names. A Key Vault secret name cannot contain :. So the provider maps a double dash - to :. A secret named Database--ConnectionString becomes the key Database:ConnectionString.
  • **DefaultAzureCredential uses your developer login locally (Azure CLI or Visual Studio sign-in) and a Managed Identity* in Azure. The same code* works in both places with no secrets. Grant the identity the Key Vault Secrets User role on the vault, and you are done.

By default, the provider loads secrets once at startup. For periodic reload, pass AzureKeyVaultConfigurationOptions with a ReloadInterval. In practice, most teams keep fast-changing values in App Configuration (next) and use Key Vault for true secrets that change rarely.

Azure App Configuration for central settings

When you have several services or instances, you want one source of truth for non-secret settings, plus dynamic refresh and feature flags. Add Microsoft.Azure.AppConfiguration.AspNetCore.

Basic load, with a label per environment:

var appConfigEndpoint = builder.Configuration["AppConfig:Endpoint"];
builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(new Uri(appConfigEndpoint!), new DefaultAzureCredential())
        // Shared keys with no label, then environment-specific overrides:
        .Select("MyApp:*", LabelFilter.Null)
        .Select("MyApp:*", builder.Environment.EnvironmentName);
});

Calling .Select twice is the normal pattern. The first call loads shared defaults. The second loads the environment-specific values, which load last and so override the defaults. Same "last wins" rule as before.

Key Vault references : secrets without splitting your config. App Configuration can store references to Key Vault secrets. So a service reads everything from one place, while the real secrets still live only in the vault:

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(new Uri(appConfigEndpoint!), new DefaultAzureCredential())
        .Select("MyApp:*", LabelFilter.Null)
        .ConfigureKeyVault(kv => kv.SetCredential(new DefaultAzureCredential()));
});

A Key Vault reference resolves on its own. Your code just sees the secret value as a normal key.

Dynamic refresh : change config without a redeploy. This is the main feature. The recommended default is RegisterAll(): refresh everything when any monitored key changes.

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(new Uri(appConfigEndpoint!), new DefaultAzureCredential())
        .Select("MyApp:*", LabelFilter.Null)
        .Select("MyApp:*", builder.Environment.EnvironmentName)
        .ConfigureRefresh(refresh =>
        {
            refresh.RegisterAll()
                   .SetRefreshInterval(TimeSpan.FromSeconds(30)); // min time between checks
        });
});
builder.Services.AddAzureAppConfiguration();   // refresher + middleware support
var app = builder.Build();
app.UseAzureAppConfiguration();   // checks for changes on incoming requests

The middleware checks for changes on requests, no more often than the refresh interval. Combine this with IOptionsSnapshot<T> (per request) or IOptionsMonitor<T> (singletons), and your typed options pick up new values without a restart.

There is also a sentinel key pattern: watch one key, and refresh everything only when that key changes. This lets you update a group of related keys, then bump the sentinel once, so consumers never see a half-updated config.

.ConfigureRefresh(refresh =>
{
    refresh.Register("MyApp:Settings:Sentinel", refreshAll: true)
           .SetRefreshInterval(TimeSpan.FromSeconds(30));
});

Use RegisterAll() for simplicity, and the sentinel pattern when you need an all-or-nothing rollout. For background services with no incoming requests, inject IConfigurationRefresherProvider and call await refresher.TryRefreshAsync() on your own schedule.

Feature flags

App Configuration has a built-in feature management system. Add Microsoft.FeatureManagement.AspNetCore, turn on flags, and register feature management:

builder.Configuration.AddAzureAppConfiguration(options =>
{
    options.Connect(new Uri(appConfigEndpoint!), new DefaultAzureCredential())
        .UseFeatureFlags(flags =>
            flags.SetRefreshInterval(TimeSpan.FromSeconds(30)));
});

builder.Services.AddFeatureManagement();
public sealed class CheckoutService(IFeatureManager features)
{
    public async Task<bool> UseNewPricingAsync()
        => await features.IsEnabledAsync("NewPricingEngine");
}

This gives you flags, percentage rollouts, and time-window filters, managed centrally and refreshed at runtime. A dedicated platform like LaunchDarkly is worth it when you need richer targeting, approval workflows, and SDKs for stacks beyond .NET. App Configuration flags are a good fit when your needs are .NET-focused. Choose by how complex your targeting and governance needs are.

How it flows through a pipeline (Managed Identity)

Here is how it works in delivery, end to end:

  • Build artifact holds appsettings.json and appsettings.{Environment}.json (non-secret values only). No secrets, ever.
  • App Service / Container App runs with a Managed Identity. Through RBAC, you grant it Key Vault Secrets User on the vault and App Configuration Data Reader on the store.
  • The pipeline adds only a few non-secret environment variables: the App Configuration endpoint and the Key Vault name (AppConfig__Endpoint, KeyVault__Name).
  • At startup, the app uses its Managed Identity to read App Configuration and Key Vault. There is no secret in the pipeline, the artifact, or source control. To rotate a secret, change it in Key Vault. To change a setting, change it in App Configuration. Neither needs a redeploy.

The goal: the same artifact runs in every environment. The identity and the central stores describe the environment.

The complete Program.cs

Here is everything from Parts 1–3 in one realistic startup file. The order is deliberate: file providers (from CreateBuilder) load first, then Key Vault, then App Configuration, so central values override file defaults.

using Azure.Identity;
using Microsoft.Extensions.Options;
var builder = WebApplication.CreateBuilder(args);
// --- Bootstrap values (non-secret) come from appsettings + environment vars ---
var keyVaultName     = builder.Configuration["KeyVault:Name"];
var appConfigEndpoint = builder.Configuration["AppConfig:Endpoint"];
var credential        = new DefaultAzureCredential(); // dev creds locally, Managed Identity in Azure
// --- 1. Azure Key Vault: true secrets ---
if (!string.IsNullOrWhiteSpace(keyVaultName))
{
    builder.Configuration.AddAzureKeyVault(
        new Uri($"https://{keyVaultName}.vault.azure.net/"),
        credential);
}
// --- 2. Azure App Configuration: central settings + flags + refresh ---
if (!string.IsNullOrWhiteSpace(appConfigEndpoint))
{
    builder.Configuration.AddAzureAppConfiguration(options =>
    {
        options.Connect(new Uri(appConfigEndpoint), credential)
            .Select("MyApp:*", LabelFilter.Null)
            .Select("MyApp:*", builder.Environment.EnvironmentName)
            .ConfigureKeyVault(kv => kv.SetCredential(credential))
            .ConfigureRefresh(refresh =>
            {
                refresh.RegisterAll()
                       .SetRefreshInterval(TimeSpan.FromSeconds(30));
            })
            .UseFeatureFlags(flags =>
                flags.SetRefreshInterval(TimeSpan.FromSeconds(30)));
    });
    builder.Services.AddAzureAppConfiguration();
    builder.Services.AddFeatureManagement();
}
// --- 3. Typed options: bind + validate + fail fast at boot ---
builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateOnStart();
builder.Services.AddSingleton<
    IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();
var app = builder.Build();
// --- 4. App Configuration middleware drives request-time refresh ---
app.UseAzureAppConfiguration();
// ... endpoints ...
app.Run();

The same binary runs everywhere. Locally it uses your developer login and appsettings.Development.json. In Azure it uses Managed Identity to read Key Vault and App Configuration.

Security checklist

  • Never commit secrets to appsettings*.json or source control. If a secret ever touched a repo, treat it as exposed.
  • Local dev → User Secrets. Cloud → Key Vault for secrets, App Configuration for settings.
  • Authenticate with Managed Identity and DefaultAzureCredential. No connection strings or client secrets in the bootstrap.
  • Limit access with RBAC, least privilege: Key Vault Secrets User, App Configuration Data Reader.
  • Keep secrets out of logs. Never log the whole options object. Never print GetDebugView() in production.

Part 4: Advanced and production

This part covers the parts you reach for less often, but that matter for performance and unusual sources: source generators, AOT, custom providers, testing, and a final list of pitfalls.

Source generators and AOT

Two source generators make configuration faster and safe for Native AOT and trimming. Both replace reflection with code generated at compile time.

The options validation source generator. When your rules fit data annotations, you do not have to write the IValidateOptions<T> by hand. Mark a partial validator class with [OptionsValidator], and the generator writes the Validate method for you. It is on automatically once your project uses Microsoft.Extensions.Options v8 or later (any modern ASP.NET Core 10 app). The generated code is reflection-free and AOT-safe.

[OptionsValidator]
public partial class DatabaseOptionsValidator
    : IValidateOptions<DatabaseOptions>
{
    // empty on purpose — the generator fills in Validate()
}

builder.Services.AddSingleton<
    IValidateOptions<DatabaseOptions>, DatabaseOptionsValidator>();
builder.Services
    .AddOptions<DatabaseOptions>()
    .BindConfiguration(DatabaseOptions.SectionName)
    .ValidateOnStart();

For nested objects and lists, mark the parent properties so the generator goes into them:

public sealed class AppOptions
{
    [ValidateObjectMembers]                 // validate the nested object
    public DatabaseOptions Database { get; set; } = new();
    [ValidateEnumeratedItems]               // validate each item in the list
    public List<EndpointOptions> Endpoints { get; set; } = new();
}

The configuration binding source generator. If you publish with Native AOT or heavy trimming, reflection-based binding causes trim warnings (IL2025, IL3050). The trimmer cannot see which members get bound at runtime. Turn on the binding generator with one project property:

<PropertyGroup>
  <EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>
</PropertyGroup>

Now Get<T>() and Bind() use generated, reflection-free code. Together with the validation generator, your whole configuration path becomes AOT-safe. This matters for containers that need a fast cold start

Custom configuration providers:

Most apps never need this. The built-in and cloud providers usually cover everything. But sometimes your values live somewhere that does not fit the list — a legacy system, a database table, or an internal platform. Then you can write your own provider.

First, ask: is the thing you want to load really configuration? It is not a place for normal application data, and not a second secret store. If it is genuinely configuration, a provider is a good fit.

The system has three parts:

  • **IConfigurationSource** — describes the source. It does not read anything. It holds the settings (like a file path) and creates the provider.
  • **ConfigurationProvider** — does the work. It reads the source and fills a dictionary of keys and values.
  • The builder — adds sources in order and builds the final result.

Say you have a simple key=value text file. The source:

public sealed class SimpleFileConfigurationSource : IConfigurationSource
{
    public string Path { get; init; } = string.Empty;
    public bool Optional { get; init; }
    public IConfigurationProvider Build(IConfigurationBuilder builder)
        => new SimpleFileConfigurationProvider(this);
}

The provider:

public sealed class SimpleFileConfigurationProvider(SimpleFileConfigurationSource source)
    : ConfigurationProvider
{
    public override void Load()
    {
        if (!File.Exists(source.Path))
        {
            if (source.Optional) { Data = new Dictionary<string, string?>(); return; }
            throw new FileNotFoundException($"Config file not found: {source.Path}");
        }

        var data = new Dictionary<string, string?>(StringComparer.OrdinalIgnoreCase);
        foreach (var line in File.ReadAllLines(source.Path))
        {
            if (string.IsNullOrWhiteSpace(line) || line.StartsWith('#')) continue;
            var separator = line.IndexOf('=');
            if (separator < 0) continue;
            var key = line[..separator].Trim();    // e.g. "PaymentApi:BaseUrl"
            var value = line[(separator + 1)..].Trim();
            data[key] = value;
        }
        Data = data;
    }
}

Load turns the file's lines into normal configuration keys and values. The rest of the app does not know they came from a text file.

Register it with a small extension method, so it reads like the built-in providers:

public static class SimpleFileConfigurationExtensions
{
    public static IConfigurationBuilder AddSimpleFile(
        this IConfigurationBuilder builder, string path, bool optional = false)
        => builder.Add(new SimpleFileConfigurationSource { Path = path, Optional = optional });
}
builder.Configuration.AddSimpleFile(
    Path.Combine(builder.Environment.ContentRootPath, "config", "extra-settings.txt"),
    optional: true);

Order matters, like the built-in providers. Add it after the others and it can override their values; add it before and the others win.

Reload support (optional). Your provider reads the file once at startup. That is often enough. But if the file can change while the app runs, watch it with a FileSystemWatcher. When the file changes, read it again and then call OnReload(). That second call is the important one — it tells the configuration system that the values changed:

var watcher = new FileSystemWatcher(folder, fileName) { EnableRaisingEvents = true };
watcher.Changed += (_, _) => { Load(); OnReload(); };

Debugging. A custom provider needs no special debugging. Once it is in the chain, the Part 1 tools work: GetDebugView shows whether your provider supplied a value, and AsEnumerable shows the final keys. If a value is missing, first check the provider is in the chain, then check which provider won. When you log inside a provider, log its behavior (loaded, reloaded, failed), never the values — config can hold secrets.

Testing configuration

Your integration tests should override configuration without touching real files or cloud stores. Use WebApplicationFactory<T>:

public sealed class ApiFactory : WebApplicationFactory<Program>
{
    protected override void ConfigureWebHost(IWebHostBuilder builder)
    {
        builder.ConfigureAppConfiguration((_, config) =>
        {
            config.AddInMemoryCollection(new Dictionary<string, string?>
            {
                ["Database:ConnectionString"] = "Server=test;Database=test;",
                ["Database:CommandTimeoutSeconds"] = "5"
            });
        });
    }
}

The in-memory provider is added last, so it overrides everything. This makes tests predictable. For unit tests of a service that takes IOptions<T>, wrap a value with Microsoft.Extensions.Options.Options.Create(new DatabaseOptions { ... }). You do not need the whole configuration system.

Anti-patterns and pitfalls

  • Injecting raw IConfiguration everywhere. String keys, no validation, no type safety. Bind to typed options instead.
  • Using IOptions<T> when you need reload. The "feature flag did nothing until restart" bug. Use IOptionsMonitor<T> or IOptionsSnapshot<T> when you need reload.
  • Captive dependency. Injecting scoped IOptionsSnapshot<T> into a singleton. Singletons must use IOptionsMonitor<T>.
  • Validation without ValidateOnStart(). Otherwise validators run only on first use. A bad config can pass the deploy and fail later.
  • Wrong env-var format. Database:ConnectionString in JSON becomes Database__ConnectionString as an environment variable.
  • Secrets in appsettings.json. The biggest mistake. Move them to Key Vault.
  • Logging the whole options object. An easy way to leak a connection string or key.

TL;DR cheat sheet

  • Reading config → bind to a typed options class. Do not use string keys on IConfiguration.
  • Missing values → reads fail quietly (null / default / empty section). Use GetRequiredSection and validation to fail early.
  • Which interfaceIOptions<T> (fixed), IOptionsSnapshot<T> (per request, reloads), IOptionsMonitor<T> (live).
  • Validation → data annotations + IValidateOptions<T> for cross-field + ValidateOnStart(). Use [OptionsValidator] to generate it.
  • AOT / trimming<EnableConfigurationBindingGenerator>true</EnableConfigurationBindingGenerator>.
  • Secrets → User Secrets (dev), Key Vault (cloud), DefaultAzureCredential + Managed Identity, RBAC.
  • Central + dynamic → App Configuration with Select by label, RegisterAll() refresh, UseAzureAppConfiguration(), UseFeatureFlags().
  • Custom sourceIConfigurationSource + ConfigurationProvider, register in order, reload with a watcher + OnReload().
  • DebuggingGetDebugView() for value + source; AsEnumerable() for the flat list. Local use only.
  • Golden rule → the same artifact runs in every environment. The identity and the central stores describe the environment.

메타데이터
post_id
4e087b378172
slug
managing-configuration-and-options-in-asp-net-core-10-4e087b378172
url
https://medium.com/@compileandconquer/managing-configuration-and-options-in-asp-net-core-10-4e087b378172
canonical_url
https://medium.com/@compileandconquer/managing-configuration-and-options-in-asp-net-core-10-4e087b378172
author_url
https://medium.com/@compileandconquer
status
ok
fetched_at
2026-06-16 19:09:56