top of page

.NET Configuration and Secrets Management

  • ShiftQuality Contributor
  • Mar 16
  • 5 min read

The previous posts in this path covered async patterns and performance profiling. This post covers the infrastructure concern that touches every .NET application: configuration — how your application knows which database to connect to, which API keys to use, and what behavior to enable in each environment — without hardcoding any of it.

A connection string in your source code is a security incident waiting to happen. The moment that code is pushed to a repository — even a private one — the credential is exposed to everyone with access. When that repository is cloned, the credential is on every developer's machine. When backups are made, the credential is in the backup. Configuration management exists to separate what the application does from the environment-specific details of how it does it.

The Configuration Hierarchy

ASP.NET Core's configuration system loads settings from multiple sources and layers them in a priority order. Later sources override earlier ones. The default order:

appsettings.json contains default settings shared across all environments. Database connection strings with placeholder values. Feature flags with default states. Logging levels. Application-wide constants.

appsettings.{Environment}.json contains environment-specific overrides. appsettings.Development.json enables detailed logging and uses a local database. appsettings.Production.json configures production logging levels and enables performance optimizations.

Environment variables override JSON settings and are the standard mechanism for configuring applications in containers and cloud environments. The environment variable ConnectionStrings__DefaultConnection overrides the ConnectionStrings.DefaultConnection setting from any JSON file. Docker, Kubernetes, and cloud platforms all inject configuration through environment variables.

User Secrets (development only) store developer-specific secrets outside the project directory, so they never appear in source control. The secrets are stored in a JSON file in the developer's user profile directory, keyed to the project.

Cloud secret stores (Azure Key Vault, AWS Secrets Manager, HashiCorp Vault) provide production-grade secret management with encryption, access control, rotation, and audit logging.

The hierarchy means your application code never changes between environments. The same code runs in development, staging, and production. Only the configuration differs — and the configuration comes from the environment, not from the codebase.

Options Pattern: Strongly Typed Configuration

Raw configuration access — Configuration["Database:ConnectionString"] — works but produces stringly-typed code that breaks silently when keys are renamed. The Options pattern maps configuration sections to strongly typed classes.

Define a class that mirrors the configuration structure. Register it in Program.cs with services.Configure<DatabaseOptions>(configuration.GetSection("Database")). Inject IOptions<DatabaseOptions> where you need it. Now your configuration is typed, validated at startup (with data annotations or custom validation), and refactorable — rename a property and the compiler catches every reference.

IOptions<T> provides a snapshot of the configuration at startup. IOptionsSnapshot<T> reloads configuration on each request — useful for settings that change without restarting the application. IOptionsMonitor<T> provides change notifications — useful for reacting to configuration changes in background services.

The practice: use IOptions<T> for settings that do not change at runtime (connection strings, external service URLs). Use IOptionsSnapshot<T> for settings that might change (feature flags, rate limits). Use IOptionsMonitor<T> for long-running services that need to react to changes (background workers, hosted services).

User Secrets for Development

The User Secrets tool stores development secrets outside your project directory. Initialize with dotnet user-secrets init, then set values with dotnet user-secrets set "Database:Password" "localdevpassword". The secrets are stored in %APPDATA%\Microsoft\UserSecrets\{UserSecretsId}\secrets.json on Windows and ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json on Linux/Mac.

User Secrets are loaded automatically in the Development environment. They override appsettings.json values, so your appsettings can contain placeholder values and the real development credentials live outside the project.

The critical point: User Secrets are not encrypted. They are stored in plain text in a known location. They are not a production secret management solution — they are a development convenience that keeps secrets out of source control. For production, use a proper secret store.

Production Secret Management

Production secrets — database credentials, API keys, encryption keys, signing certificates — require encryption at rest, access control, audit logging, and rotation capabilities. None of these are provided by environment variables or JSON files.

Azure Key Vault integrates directly with ASP.NET Core's configuration system. Add the Key Vault configuration provider, and secrets stored in Key Vault appear as configuration values alongside appsettings and environment variables. Access is controlled through Azure AD — the application authenticates with a managed identity (no credentials to manage), and Key Vault policies control which secrets the application can read.

The workflow: developers use User Secrets locally. CI/CD pipelines use pipeline secrets (GitHub Secrets, Azure DevOps Variable Groups) for build-time configuration. Production applications read from Key Vault (or your cloud provider's equivalent) at runtime.

Secret rotation is the practice that most teams skip until it bites them. A database password should be rotatable without application downtime. This means the application must handle credential refresh — either by periodically re-reading from the secret store or by using IOptionsMonitor<T> to react to configuration changes.

Configuration Validation

An application that starts with an empty connection string will fail — but it might fail on the first database query, five minutes after startup, in a way that looks like a database outage rather than a configuration error. Early validation catches these problems at startup.

The Options pattern supports validation through data annotations ([Required], [Range]) and custom validation logic. Register options with .ValidateDataAnnotations() or .Validate(options => ...) and the application fails fast at startup if required configuration is missing or invalid.

The practice: validate all required configuration at startup. Connection strings must be non-empty. API URLs must be valid URIs. Numeric settings must be within expected ranges. Feature flags must be parseable. A clear startup error — "Database:ConnectionString is required but was not configured" — saves hours of debugging compared to a runtime NullReferenceException in a seemingly unrelated component.

Feature Flags

Feature flags are configuration values that control which features are active. They decouple deployment from release — you can deploy code that includes a new feature but keep the feature disabled until you are ready to enable it. If the feature causes problems, disable the flag instead of rolling back the deployment.

.NET's configuration system makes simple feature flags straightforward — a boolean in appsettings that is read through the Options pattern. For more sophisticated flag management — percentage rollouts, user targeting, A/B testing — dedicated feature flag services (Azure App Configuration, LaunchDarkly, Unleash) provide management UIs, gradual rollouts, and real-time flag changes without redeployment.

The implementation pattern: check the flag at the point where behavior diverges. In a controller, check the flag before calling the new service implementation. In middleware, check the flag before applying new processing logic. Keep the flag checks clean and centralized — scattered flag checks across the codebase become their own form of technical debt.

The Takeaway

.NET's configuration system provides a layered hierarchy — JSON files for defaults, environment-specific overrides, environment variables for deployment configuration, User Secrets for development, and cloud secret stores for production. The Options pattern makes configuration strongly typed and validatable.

The principle: secrets never go in source control. Configuration varies by environment. Validation happens at startup. And the application code is identical across all environments — only the configuration that feeds it changes.

Next in the ".NET at Scale" learning path: We'll cover distributed tracing in .NET — connecting diagnostic data across microservices to understand the full lifecycle of a request.

Comments


bottom of page