Configuration Management – Settings for Every Environment
Consider a test suite that started cleanly: one environment, one base URL, a handful of credentials. Six months later it targets Development, Staging, and Production, each with different URLs, different databases, different API keys, different browser settings, and different timeout thresholds. Someone added an if statement to switch base URLs. Then another for the database. Then the database password got committed to the repository. Then CI started failing silently when a required variable wasn't set, and the error message pointed nowhere near the actual cause. The configuration is now scattered across a dozen files, inconsistently named, partially duplicated, and containing secrets that will never fully leave the git history. None of this is unusual – it's what configuration drift looks like in practice, and it happens to almost every test suite that grows without a deliberate configuration strategy.
This lesson covers Microsoft.Extensions.Configuration, the layered configuration system built into .NET that solves these problems systematically. It works through a stack of providers – JSON files, environment variables, user secrets, command-line arguments – where each layer can override the one beneath it. The result is a single strongly-typed settings object that the DI container can inject anywhere in the framework, whose values change per environment without a single line of code changing. The lesson covers provider ordering, layered JSON files, strongly-typed binding, environment variable conventions for CI/CD, and secrets management patterns that keep credentials out of source control.
Configuration management is the connective tissue between the DI architecture from the previous lesson and the real-world environments test code has to navigate. Get it right once and the framework runs everywhere it needs to without modification.
Why Configuration Management Matters
The problem isn't that test frameworks need configuration – every framework does. The problem is the patterns that emerge when configuration isn't treated as a first-class concern. Hardcoded values start as shortcuts and accumulate into architecture. A hardcoded base URL becomes three hardcoded URLs with an environment switch. The environment switch duplicates across files. Credentials appear directly in test helpers. The CI pipeline uses different values that someone remembered to set manually and never documented.
The Four Configuration Failure Modes
Most configuration problems in test frameworks fall into four categories, all of which a proper configuration layer eliminates:
- Hardcoding: Values embedded directly in source code that can't be changed without a commit. Common for base URLs, timeout values, and database server names.
- Secret exposure: Passwords, API keys, or connection strings committed to source control. Once committed, the secret is in git history permanently – rotating the credential isn't enough without also purging history.
- Inconsistent environment switching: Ad-hoc if/else or switch statements based on an environment name string, scattered across multiple files with no single source of truth.
- Silent CI failures: Missing environment variables that cause tests to run against the wrong environment or with null settings, producing failures that look like application bugs rather than configuration problems.
Configuration as Architecture
The right approach treats configuration as a distinct architectural layer: gathered at application startup from a prioritised set of sources, validated before any test runs, bound to strongly-typed objects that the compiler can check, and injected through the DI container like any other service. Changing the environment changes the values flowing through this layer – nothing else in the framework changes:
// The goal: a single settings object that works in every environment.
// Its values change per environment; the code that uses it doesn't.
public class TestSettings
{
public EnvironmentSettings Environment { get; init; } = new();
public BrowserSettings Browser { get; init; } = new();
public TimeoutSettings Timeouts { get; init; } = new();
public DatabaseSettings Database { get; init; } = new();
// Credentials live here too – injected from secrets, not hardcoded
public string AdminEmail { get; init; } = string.Empty;
public string AdminPassword { get; init; } = string.Empty;
}
public class EnvironmentSettings
{
public string Name { get; init; } = "Development";
public string BaseUrl { get; init; } = "http://localhost:5000";
public string ApiUrl { get; init; } = "http://localhost:5001";
}
public class BrowserSettings
{
public string Browser { get; init; } = "Chrome";
public bool Headless { get; init; } = false;
public int Width { get; init; } = 1920;
public int Height { get; init; } = 1080;
}
public class TimeoutSettings
{
public int ImplicitWaitSeconds { get; init; } = 10;
public int PageLoadSeconds { get; init; } = 30;
public int ApiTimeoutSeconds { get; init; } = 15;
}
The strongly-typed settings class is the destination everything in this lesson is building toward. Every configuration pattern described here – JSON layers, environment variables, secrets – exists to populate this object correctly for each environment without exposing sensitive values or requiring code changes.
The Configuration Provider Stack
Microsoft.Extensions.Configuration is built around the concept of a provider stack: multiple sources of configuration data, each registered as a provider, processed in registration order. Later providers override earlier ones for the same key. This priority ordering is the mechanism that makes environment-specific values work without code changes.
How Providers Layer
A typical test framework configuration stack, in registration order, looks like this:
using Microsoft.Extensions.Configuration;
// Determine the active environment from an env variable – default to Development
var activeEnvironment = System.Environment.GetEnvironmentVariable("TEST_ENV")
?? "Development";
var config = new ConfigurationBuilder()
// 1. Base defaults – apply everywhere, lowest priority
.AddJsonFile("appsettings.json", optional: false, reloadOnChange: false)
// 2. Environment overrides – optional; only present for specific environments
// Values here override the matching keys from appsettings.json
.AddJsonFile($"appsettings.{activeEnvironment}.json", optional: true, reloadOnChange: false)
// 3. Environment variables with the "TEST_" prefix – higher priority than JSON.
// CI/CD systems inject values here. Overrides JSON and local files.
.AddEnvironmentVariables(prefix: "TEST_")
// 4. User secrets – for local development only, never committed to source control.
// Highest priority for local runs; absent on CI (which uses env vars instead).
.AddUserSecrets<TestSettings>(optional: true)
.Build();
// The IConfiguration object now holds the merged, prioritised view
// of all registered sources. Key lookup traverses providers in reverse order.
Key Naming and Sections
Configuration keys follow a hierarchical naming convention using colon separators. A JSON object property like Browser.Headless maps to the key "Browser:Headless". Environment variable flattening uses double underscore as the separator (since colons aren't valid in all shells): TEST_Browser__Headless=true maps to the same key. The framework normalises these automatically:
// Reading individual values – not recommended for large setting surfaces
string? baseUrl = config["Environment:BaseUrl"];
string? browserStr = config.GetSection("Browser")["Name"];
bool headless = config.GetValue<bool>("Browser:Headless");
// GetValue<T> with a default – for optional settings
int implicitWait = config.GetValue<int>("Timeouts:ImplicitWaitSeconds", defaultValue: 10);
// GetSection isolates a configuration subtree for targeted binding
IConfigurationSection browserSection = config.GetSection("Browser");
string? name = browserSection["Name"]; // reads "Browser:Name"
reloadOnChange and Test Suites
The reloadOnChange: true option causes the JSON provider to monitor the file and reload configuration when it changes on disk. For web applications this is useful; for test suites it's usually counterproductive. Test runs have a defined start and end point – configuration loaded at startup should be stable for the entire run. Enabling reloadOnChange in tests creates a race condition where configuration might change mid-run if a file is touched externally. Always use reloadOnChange: false in test infrastructure.
The provider stack model makes the priority hierarchy explicit rather than implicit. Every developer on the team can read the ConfigurationBuilder chain and immediately understand which sources can override which – no tribal knowledge required, no documentation needed beyond the code itself.
JSON Files and Environment Layers
JSON configuration files are the most readable layer of the stack: they're version-controlled, reviewable in pull requests, and self-documenting. The pattern is a base appsettings.json containing defaults valid for all environments, supplemented by environment-specific override files that only contain the values that differ.
The Base Configuration File
appsettings.json should contain sensible defaults for local development – the values a developer needs to run tests locally without any additional setup. No secrets, no production values, no staging URLs. Just the working defaults for development:
{
"Environment": {
"Name": "Development",
"BaseUrl": "http://localhost:5000",
"ApiUrl": "http://localhost:5001"
},
"Browser": {
"Name": "Chrome",
"Headless": false,
"Width": 1920,
"Height": 1080
},
"Timeouts": {
"ImplicitWaitSeconds": 10,
"PageLoadSeconds": 30,
"ApiTimeoutSeconds": 15
},
"Database": {
"Server": "localhost",
"Name": "TestAutomationDb",
"Port": 5432
}
}
Environment Override Files
Override files are sparse – they contain only the keys that differ from the base. The configuration system merges them over the base at load time. Staging only needs the values unique to Staging; it inherits everything else from appsettings.json:
// appsettings.Staging.json – only values that differ from development defaults
{
"Environment": {
"Name": "Staging",
"BaseUrl": "https://staging.myapp.com",
"ApiUrl": "https://api-staging.myapp.com"
},
"Browser": {
"Headless": true
},
"Timeouts": {
"ImplicitWaitSeconds": 15,
"PageLoadSeconds": 60,
"ApiTimeoutSeconds": 30
}
}
// appsettings.Production.json – production has different URLs and stricter timeouts
{
"Environment": {
"Name": "Production",
"BaseUrl": "https://myapp.com",
"ApiUrl": "https://api.myapp.com"
},
"Browser": {
"Headless": true
},
"Timeouts": {
"ImplicitWaitSeconds": 20,
"PageLoadSeconds": 90,
"ApiTimeoutSeconds": 45
}
}
What Never Goes in JSON Files
Three categories of values must never appear in any JSON configuration file that's committed to source control: passwords and credentials, API keys and tokens, and database connection strings containing authentication details. This includes both appsettings.json and the environment override files. The moment a secret enters a committed file, it's in git history permanently. Use environment variables (on CI) and user secrets (locally) for all sensitive values.
Add appsettings.*.json to .gitignore
A common pattern is to commit appsettings.json and appsettings.json.template (with placeholder values), but gitignore appsettings.Development.json and any file that might contain developer-specific settings. The template shows the expected structure without exposing real values. Add appsettings.*.local.json to .gitignore as an additional safety valve for any local override file a developer might create. The CI pipeline's configuration comes exclusively from environment variables, not from files.
The JSON layer pattern is most useful for values that change by environment but aren't sensitive – URLs, port numbers, timeout thresholds, browser settings. Keep the files small and sparse. When a file starts containing dozens of environment-specific overrides, it's often a sign that the application has too many environment differences and some consolidation is warranted.
Strongly-Typed Configuration
Reading configuration through string keys – config["Browser:Headless"] – works but produces no compile-time safety. A typo in the key returns null silently. Removing a setting doesn't cause a compiler error anywhere it was referenced. Strongly-typed configuration binding maps the raw key-value store to a typed POCO class, catching structural mismatches at startup and providing full IntelliSense everywhere the settings are used.
Binding with IConfiguration.Bind
The simplest binding approach creates a settings instance and populates it from a configuration section. The property names in the POCO must match the JSON keys (case-insensitively). Nested objects bind recursively:
// Bind the entire configuration to a root settings object
var settings = new TestSettings();
config.Bind(settings);
// Or bind a specific section to a settings subset
var browserSettings = new BrowserSettings();
config.GetSection("Browser").Bind(browserSettings);
// The cleaner one-liner using Get<T>() – returns a new populated instance
TestSettings settings = config.Get<TestSettings>()
?? throw new InvalidOperationException("Configuration binding returned null.");
// Validate required values immediately after binding – fail fast at startup,
// not mid-run when the value is first used
if (string.IsNullOrWhiteSpace(settings.Environment.BaseUrl))
throw new InvalidOperationException("Environment:BaseUrl is required.");
if (string.IsNullOrWhiteSpace(settings.AdminEmail))
throw new InvalidOperationException("AdminEmail is required (use user secrets or env vars).");
Injecting Settings Through DI
Once bound, the settings object becomes a singleton in the DI container – loaded once, validated once, accessible throughout the framework via constructor injection. This connects directly to the composition root from the previous lesson:
// In the composition root (TestServices.Build)
public static IServiceProvider Build()
{
// Load and validate configuration first
var activeEnvironment = System.Environment.GetEnvironmentVariable("TEST_ENV")
?? "Development";
var config = new ConfigurationBuilder()
.AddJsonFile("appsettings.json", optional: false)
.AddJsonFile($"appsettings.{activeEnvironment}.json", optional: true)
.AddEnvironmentVariables(prefix: "TEST_")
.AddUserSecrets<TestSettings>(optional: true)
.Build();
var settings = config.Get<TestSettings>()
?? throw new InvalidOperationException("Configuration binding failed.");
ValidateSettings(settings); // custom validation method – throw early on missing values
// Register the validated settings object as a singleton
var services = new ServiceCollection();
services.AddSingleton(settings);
services.AddSingleton(settings.Environment);
services.AddSingleton(settings.Browser);
services.AddSingleton(settings.Timeouts);
// Other service registrations can depend on settings directly
services.AddSingleton<IWebDriverFactory>(sp =>
{
var browser = sp.GetRequiredService<BrowserSettings>();
return new WebDriverFactory(browser.Name, browser.Headless, browser.Width, browser.Height);
});
return services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true
});
}
The IOptions Pattern
For frameworks that use the full ASP.NET Core hosting model, IOptions<T> is the standard way to inject configuration sections. It supports change notifications (IOptionsMonitor<T>) and scoped settings (IOptionsSnapshot<T>). For test frameworks without the full host, registering the bound POCO directly as a singleton – as shown above – is simpler and carries no hidden complexity:
// IOptions pattern – used when working within the generic host or web host
services.Configure<BrowserSettings>(config.GetSection("Browser"));
services.Configure<TimeoutSettings>(config.GetSection("Timeouts"));
// Classes that consume it declare IOptions<T> in their constructor
public class WebDriverFactory
{
private readonly BrowserSettings _settings;
public WebDriverFactory(IOptions<BrowserSettings> options)
{
_settings = options.Value; // .Value accesses the bound instance
}
}
// When NOT using the generic host (most test framework scenarios):
// Simply register the bound POCO and inject it directly.
// This is less ceremony and easier to understand.
services.AddSingleton<BrowserSettings>(settings.Browser);
public class WebDriverFactory
{
private readonly BrowserSettings _settings;
public WebDriverFactory(BrowserSettings settings) => _settings = settings;
}
Validate Eagerly, Fail Loudly
The single most valuable practice in configuration management is early validation. Write a ValidateSettings(TestSettings settings) method that checks every required field before any test runs. A missing base URL should produce "Required configuration key 'Environment:BaseUrl' is missing or empty" at startup, not a NullReferenceException when the first test tries to navigate. Early validation eliminates an entire category of debugging sessions where the symptom is a test failure and the root cause is a missing environment variable that someone forgot to set in a new CI stage.
Strongly-typed configuration is the investment that makes the rest of the configuration system worth the effort. Once settings arrive as typed objects through constructor injection, every consumer gets IntelliSense, compile-time checking, and null safety – all without knowing or caring how those values were loaded.
Environment Variables and CI/CD
Environment variables are the standard mechanism for passing configuration into processes on any platform – Linux, Windows, macOS, and every major CI/CD system. They're the correct layer for values that change between CI runs, for credentials that must be injected from a secure vault, and for any setting that a developer needs to override locally without modifying files.
The Prefix Convention
Without a prefix, AddEnvironmentVariables() loads every environment variable on the system – including system variables like PATH and HOME that have nothing to do with your tests. A TEST_ prefix scopes the collection to test-specific variables and eliminates conflicts. The prefix is stripped before the key is added to the configuration store:
// These environment variables set configuration values when prefixed with TEST_:
// Sets Environment:BaseUrl → "https://staging.myapp.com"
// TEST_Environment__BaseUrl=https://staging.myapp.com
// Note: colons are not valid in env var names on Linux.
// Double underscore (__) is the standard separator for nested config in env vars.
// The configuration system converts __ to : automatically.
// TEST_Browser__Headless=true → Browser:Headless = true
// TEST_Timeouts__PageLoadSeconds=60 → Timeouts:PageLoadSeconds = 60
// [email protected] → AdminEmail = [email protected]
// Registration: prefix stripped on load, __ converted to : for nesting
.AddEnvironmentVariables(prefix: "TEST_")
CI/CD Pipeline Configuration
Most CI/CD platforms provide native mechanisms for setting environment variables, including secure storage for secrets that are injected as variables at runtime. The test framework doesn't need platform-specific code – it reads standard environment variables regardless of which system set them:
// GitHub Actions workflow: setting environment variables for a test job
// (stored in repository secrets, injected at runtime – never visible in logs)
{
"jobs": {
"test": {
"runs-on": "ubuntu-latest",
"env": {
"TEST_ENV": "Staging",
"TEST_Browser__Headless": "true",
"TEST_Environment__BaseUrl": "$",
"TEST_Environment__ApiUrl": "$",
"TEST_AdminEmail": "$",
"TEST_AdminPassword": "$",
"TEST_Database__ConnectionString": "$"
}
}
}
}
// Azure DevOps pipeline: equivalent pattern with pipeline variables
// Variables marked as secret are masked in logs and injected as env vars
{
"variables": {
"TEST_ENV": "Staging",
"TEST_Browser__Headless": "true",
"TEST_Environment__BaseUrl": "$(STAGING_BASE_URL)",
"TEST_AdminEmail": "$(STAGING_ADMIN_EMAIL)",
"TEST_AdminPassword": "$(STAGING_ADMIN_PASSWORD)"
}
}
Validating Required Variables at Startup
A CI pipeline that's missing a required variable should fail immediately with a clear error, not halfway through the test run with a cryptic null reference. A startup validation method reads the bound settings and throws descriptive errors for any missing required values:
private static void ValidateSettings(TestSettings settings)
{
var errors = new List<string>();
if (string.IsNullOrWhiteSpace(settings.Environment.BaseUrl))
errors.Add("Environment:BaseUrl is required. Set TEST_Environment__BaseUrl.");
if (string.IsNullOrWhiteSpace(settings.Environment.ApiUrl))
errors.Add("Environment:ApiUrl is required. Set TEST_Environment__ApiUrl.");
if (string.IsNullOrWhiteSpace(settings.AdminEmail))
errors.Add("AdminEmail is required. Set TEST_AdminEmail (use user secrets locally).");
if (string.IsNullOrWhiteSpace(settings.AdminPassword))
errors.Add("AdminPassword is required. Set TEST_AdminPassword (use user secrets locally).");
if (errors.Count > 0)
{
var message = "Test configuration validation failed:\n" +
string.Join("\n", errors.Select(e => $" – {e}"));
throw new InvalidOperationException(message);
}
}
// Called immediately after binding, before any service is registered:
// ValidateSettings(settings);
// → Output on failure:
// Test configuration validation failed:
// – AdminEmail is required. Set TEST_AdminEmail (use user secrets locally).
// – AdminPassword is required. Set TEST_AdminPassword (use user secrets locally).
Environment variables are the lingua franca of modern CI/CD configuration. Every platform supports them. Every language reads them. The test framework's configuration layer should be nothing more than a translator between environment variables and strongly-typed objects – and the validation step is what makes that translation trustworthy.
Secrets Management for Test Teams
Secrets – passwords, API keys, connection strings with credentials – require different treatment from other configuration values. They must never appear in source control. They must be rotatable without code changes. They must be auditable. And they must be usable by both local developers and CI/CD pipelines without special-casing in the framework code.
.NET User Secrets for Local Development
User Secrets is a .NET feature that stores sensitive configuration values in a file outside the project directory, in the user's profile folder, where they can't be accidentally committed. The secrets file is linked to the project by a UserSecretsId in the .csproj file and is never included in the project output:
// Step 1: Enable user secrets in the test project's .csproj file
// <PropertyGroup>
// <UserSecretsId>test-automation-secrets-{guid}</UserSecretsId>
// </PropertyGroup>
// Step 2: Set secrets via the .NET CLI (run once per developer machine)
// dotnet user-secrets set "AdminEmail" "[email protected]"
// dotnet user-secrets set "AdminPassword" "StagingAdminPass123!"
// dotnet user-secrets set "Database:ConnectionString" "Server=staging-db;User=test;Password=..."
// Step 3: Add user secrets to the configuration builder
// The generic type parameter points to any type in the target assembly.
// The secrets file is located automatically from the UserSecretsId.
.AddUserSecrets<TestSettings>(optional: true)
// optional: true means the app starts normally when no secrets file exists.
// This is correct: CI uses environment variables, not secrets files.
// Local developers use secrets files; they don't need environment variables set.
// The secrets file location (for reference – never edit directly):
// Windows: %APPDATA%\Microsoft\UserSecrets\{UserSecretsId}\secrets.json
// Linux/macOS: ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json
Secrets on CI/CD Pipelines
CI/CD platforms treat secrets as a separate concern from configuration: they're stored in a secure vault or secret manager, injected as environment variables at runtime, masked in log output, and never written to disk. The test framework reads them as environment variables – it doesn't know and doesn't need to know that they came from GitHub Secrets, Azure Key Vault, HashiCorp Vault, or any other source:
// The framework's perspective: secrets arrive as environment variables.
// Whether they came from GitHub Secrets, Azure Key Vault, or Vault doesn't matter.
// GitHub Actions: reference a repository secret (stored in repo settings, not code)
// $ is substituted at workflow execution time.
// The value is masked in logs (appears as ***) and is never accessible to the workflow file.
// Azure DevOps: variable groups with secret variables work identically.
// AWS CodeBuild: secrets from AWS Secrets Manager injected as env vars.
// All of them appear to the framework as TEST_AdminPassword=<value>.
// The key insight: the framework's configuration layer is platform-agnostic.
// It reads TEST_AdminPassword from the environment – the source is irrelevant.
// This makes the framework portable across CI platforms without modification.
A Layered Local Developer Setup
The full layered configuration approach for a local developer combines all three sources cleanly. The developer runs tests locally, their user secrets override the base JSON, and CI uses only environment variables. The composition root code never changes between contexts:
// The complete configuration builder – identical for local dev and CI.
// Local dev: secrets file provides AdminEmail, AdminPassword, ConnectionString.
// CI: environment variables with TEST_ prefix provide the same values.
// The priority ordering ensures the right source wins in each context.
public static TestSettings LoadSettings()
{
var activeEnvironment = System.Environment.GetEnvironmentVariable("TEST_ENV")
?? "Development";
var config = new ConfigurationBuilder()
.SetBasePath(AppContext.BaseDirectory) // or Directory.GetCurrentDirectory()
// Layer 1: base defaults – lowest priority
.AddJsonFile("appsettings.json", optional: false)
// Layer 2: environment overrides – URLs, timeouts, browser mode
.AddJsonFile($"appsettings.{activeEnvironment}.json", optional: true)
// Layer 3: environment variables – CI injects secrets and env-specific values here
.AddEnvironmentVariables(prefix: "TEST_")
// Layer 4: user secrets – local developer credentials, highest priority
// optional: true → CI machines have no secrets file; they use env vars (layer 3)
.AddUserSecrets<TestSettings>(optional: true)
.Build();
var settings = config.Get<TestSettings>()
?? throw new InvalidOperationException(
$"Configuration could not be bound to {nameof(TestSettings)}.");
ValidateSettings(settings);
return settings;
}
// Priority summary (highest to lowest):
// Local dev: User Secrets > Environment Variables > appsettings.{env}.json > appsettings.json
// CI pipeline: Environment Variables > appsettings.{env}.json > appsettings.json
// (No user secrets file on CI; TEST_* env vars contain everything CI needs)
Rotation Is Not Recovery
When a credential is committed to source control, rotating the password is necessary but not sufficient. The old credential remains in git history and is retrievable by anyone with repository access – including anyone who cloned the repository before the rotation. The complete remediation requires: rotating the credential, purging it from git history (using tools like git filter-repo), force-pushing the rewritten history, and notifying all team members to reclone. The effort required makes the incident much more costly than the few minutes saved by committing the password in the first place. Treat credentials in git history as a security incident, not a minor inconvenience.
Secrets management is the part of configuration that teams most often defer until after an incident. The setup cost is low: one <UserSecretsId> in the project file, a one-time dotnet user-secrets set per developer, and an AddUserSecrets call in the configuration builder. The operational cost of not having it – a password in git history, a CI pipeline that can't run without a developer's local settings file – is far higher.
Key Takeaways
- Hardcoded configuration is technical debt from day one. Every hardcoded URL, timeout, or credential is a future change that requires a commit, a review, and a deployment. Treat configuration as a distinct layer from the start, not as something to extract "later when it becomes a problem".
- Microsoft.Extensions.Configuration uses a layered provider stack. Providers are registered in order; later registrations override earlier ones for the same key. The standard test stack is: base
appsettings.json→ environment override JSON → environment variables → user secrets. Higher layers win. - appsettings.json holds non-sensitive defaults for all environments. Override files (
appsettings.Staging.json) are sparse – they contain only the keys that differ from the base. Never put credentials, connection strings with passwords, or API keys in any committed JSON file. - Double underscore maps nested JSON keys in environment variable names.
TEST_Browser__Headless=truesetsBrowser:Headlessin the configuration store. Colons are not valid in Linux environment variable names; the configuration system converts__to:automatically. - Strongly-typed binding eliminates string-key fragility. Use
config.Get<T>()to bind the entire configuration to a typed POCO. Validate required fields immediately after binding – fail at startup with a clear message, not mid-run with aNullReferenceException. - User Secrets are the correct local developer credentials mechanism. The
dotnet user-secretsCLI stores values in the user's profile folder, outside the repository. Secrets files are never committed. CI pipelines use environment variables instead of secrets files – the configuration builder handles both transparently. - CI/CD platforms inject secrets as environment variables. GitHub Secrets, Azure DevOps variable groups, AWS Secrets Manager, and HashiCorp Vault all ultimately surface values as environment variables. The test framework's
AddEnvironmentVariables(prefix: "TEST_")call reads them without knowing or caring about the source. - A committed secret requires git history rewriting, not just rotation. Rotating the credential is necessary but doesn't remove it from history. Prevent the incident with secrets management infrastructure; if it happens, treat it as a security incident requiring full remediation including history rewriting and team notification.
Further Reading
- Configuration in .NET (Microsoft) The official .NET configuration documentation: provider model, binding, options pattern, IConfiguration API, and how to write custom providers. The definitive reference for Microsoft.Extensions.Configuration.
- Safe Storage of App Secrets in Development (Microsoft) Complete guide to .NET User Secrets: enabling the feature, using the CLI to set and list secrets, how secrets integrate with the configuration builder, and why they're the correct local-development pattern for sensitive values.
- Encrypted Secrets in GitHub Actions GitHub's documentation for repository and organisation secrets: creating, rotating, and referencing secrets in workflow files. Covers the masking behaviour that prevents secrets from appearing in CI logs.
- Options Pattern in .NET (Microsoft) Deep reference for IOptions<T>, IOptionsSnapshot<T>, and IOptionsMonitor<T>: when each variant is appropriate, how named options work, and how to validate options at startup using data annotations or custom validators.