One Rule: Secrets Don't Belong in Source Control
Connection strings, API keys, signing certificates, SMTP passwords — if it grants access to something, it has no business sitting in appsettings.json. That file gets committed to Git, copied into build artefacts, and forwarded in Slack threads. Every one of those journeys is an opportunity for exposure.
.NET's IConfiguration system makes the right approach easy: different providers for different environments, layered so a more-specific source always wins over a less-specific one. The challenge isn't technical — it's knowing which of the three main options to reach for and why. User Secrets, environment variables, and Azure Key Vault each serve a distinct purpose. Using the wrong one creates either a security gap or unnecessary operational friction.
This article gives you a concrete decision rule for each, the exact code to wire each one into a .NET 8 app, and the patterns that keep secrets out of logs, stack traces, and process dumps at runtime.
User Secrets: The Right Tool for Local Development
User Secrets store per-developer configuration in a JSON file outside the project directory — under your OS user profile — so the file can never be accidentally committed to source control no matter how carelessly someone runs git add .. They are only loaded when ASPNETCORE_ENVIRONMENT is Development, so they have zero chance of leaking into staging or production builds.
They are not encrypted. They are protected by file system permissions alone. That is the correct trade-off for a local development tool — encryption at rest would add friction with no meaningful security benefit for a file only accessible to the logged-in developer.
// Initialise User Secrets for the project (adds a UserSecretsId to the .csproj)
// dotnet user-secrets init
// Set individual secrets — stored outside the project directory
// dotnet user-secrets set "Database:ConnectionString" "Server=localhost;Database=MyAppDev;..."
// dotnet user-secrets set "Stripe:SecretKey" "sk_test_abc123..."
// dotnet user-secrets set "SendGrid:ApiKey" "SG.xxx..."
// List all secrets for the current project
// dotnet user-secrets list
// Remove a specific secret
// dotnet user-secrets remove "Stripe:SecretKey"
// Clear all secrets for this project
// dotnet user-secrets clear
// Secrets are stored here (never inside your project folder):
// Windows: %APPDATA%\Microsoft\UserSecrets\{UserSecretsId}\secrets.json
// Linux: ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json
// macOS: ~/.microsoft/usersecrets/{UserSecretsId}/secrets.json
// WebApplication.CreateBuilder automatically adds User Secrets
// when Environment is Development — no manual registration needed.
var builder = WebApplication.CreateBuilder(args);
// Read via IConfiguration — same key path as appsettings.json
var connStr = builder.Configuration["Database:ConnectionString"];
// Better: bind to a typed options class
builder.Services.Configure(
builder.Configuration.GetSection("Database"));
// Verify User Secrets are loaded (Development only — remove before shipping)
if (builder.Environment.IsDevelopment())
{
var secretsId = builder.Configuration["UserSecretsId"];
// Log which secrets are present (keys only — never log values)
var configKeys = (builder.Configuration as IConfigurationRoot)?
.GetDebugView()
.Split(Environment.NewLine)
.Where(line => line.Contains("Secrets"))
.ToList();
}
// The configuration priority order (last wins):
// 1. appsettings.json
// 2. appsettings.Development.json
// 3. User Secrets ← overrides both appsettings files
// 4. Environment variables ← overrides User Secrets
// 5. Command-line args ← overrides everything
When to use User Secrets: local development only. Every developer on the team runs dotnet user-secrets set once to configure their own local credentials. No shared secrets file, no appsettings.local.json in .gitignore, no secrets in the repo at all. The UserSecretsId GUID in the .csproj is the only secrets-related thing that belongs in source control.
Environment Variables: The Right Tool for Containers and CI/CD
Environment variables are the lingua franca of containerised workloads. Docker, Kubernetes, GitHub Actions, Azure DevOps pipelines — every platform knows how to inject them. They require no SDK, no library, no Azure dependency. A container image built for AWS works identically on Azure if you inject the right variables at runtime.
.NET maps the colon-separated key hierarchy from appsettings.json to double-underscore separated environment variable names. Database:ConnectionString in configuration becomes DATABASE__CONNECTIONSTRING as an environment variable — the double underscore is the cross-platform-safe separator because single colons are not valid in variable names on all platforms.
// ── Kubernetes: inject secrets from a Secret object ───────────────────────
/*
apiVersion: apps/v1
kind: Deployment
spec:
template:
spec:
containers:
- name: api
image: yourcompany/api:latest
env:
# Single key from a Kubernetes Secret
- name: Database__ConnectionString
valueFrom:
secretKeyRef:
name: api-secrets
key: db-connection-string
- name: Stripe__SecretKey
valueFrom:
secretKeyRef:
name: api-secrets
key: stripe-secret-key
# Non-secret config as plain env vars
- name: ASPNETCORE_ENVIRONMENT
value: Production
- name: ASPNETCORE_URLS
value: "http://+:8080"
---
# Create the Kubernetes Secret (base64-encoded values)
# kubectl create secret generic api-secrets \
# --from-literal=db-connection-string="Server=prod-db;..." \
# --from-literal=stripe-secret-key="sk_live_..."
*/
// ── Program.cs — nothing special needed, env vars load automatically ──────
var builder = WebApplication.CreateBuilder(args);
// Env vars override appsettings.json automatically — no registration needed.
// Double-underscore maps to colon: Database__Host → Database:Host
var dbHost = builder.Configuration["Database:Host"];
// Typed options — same binding regardless of where the value comes from
builder.Services.Configure(
builder.Configuration.GetSection("Stripe"));
// ── Docker Compose: .env file pattern for local multi-service dev ─────────
/*
.env (committed — safe placeholder values only):
DATABASE__CONNECTIONSTRING=Server=localhost;Database=myapp_dev;User=dev;Password=localdev
STRIPE__SECRETKEY=sk_test_placeholder
.env.local (gitignored — real local credentials):
DATABASE__CONNECTIONSTRING=Server=localhost;Database=myapp_dev;User=dev;Password=YourRealLocalPassword
STRIPE__SECRETKEY=sk_test_YourRealTestKey
docker-compose.yml:
services:
api:
env_file:
- .env
- .env.local # overrides .env — .local is gitignored
*/
// ── Never log environment variable values — log keys only ─────────────────
var configuredKeys = builder.Configuration
.AsEnumerable()
.Where(kv => kv.Value is not null)
.Select(kv => kv.Key)
.OrderBy(k => k);
// app.Logger.LogInformation("Config keys loaded: {Keys}", configuredKeys);
// ↑ Safe — logs key names only, never values
When to use environment variables: any non-local environment where you control deployment — containers, CI/CD pipelines, VMs, PaaS platforms. They are the right default for staging and production workloads that don't have an Azure dependency. They are not the right choice when secrets need rotation without restarting the process — environment variables are baked in at startup and can't be refreshed at runtime.
Azure Key Vault: The Right Tool for Production Secrets That Rotate
Key Vault is a centralised secrets store with audit logging, access policies, versioning, and automatic rotation for supported services. Every read is logged. Every secret has a version history. Access is controlled by Azure RBAC roles, not by who has the connection string to the key store. When a developer leaves your organisation, you revoke their Key Vault access — you don't rotate every secret they knew.
The .NET integration loads Key Vault secrets into IConfiguration at startup using the same provider model as every other source. Your application code reads builder.Configuration["Stripe:SecretKey"] — it has no idea whether the value came from a local JSON file, an environment variable, or Key Vault. The provider is the only thing that changes.
using Azure.Extensions.AspNetCore.Configuration.Secrets;
using Azure.Identity;
using Azure.Security.KeyVault.Secrets;
var builder = WebApplication.CreateBuilder(args);
// ── Add Key Vault — only in non-Development environments ──────────────────
// This keeps local dev fast (no Key Vault round-trip) and uses User Secrets instead
if (!builder.Environment.IsDevelopment())
{
var kvUri = builder.Configuration["KeyVault:Uri"]
?? throw new InvalidOperationException(
"KeyVault:Uri must be set in appsettings.{Environment}.json");
// DefaultAzureCredential tries (in order):
// 1. Managed Identity (in Azure)
// 2. Azure CLI login (local dev if you want Key Vault without User Secrets)
// 3. Visual Studio credential
// 4. Environment variables (AZURE_CLIENT_ID, AZURE_CLIENT_SECRET, AZURE_TENANT_ID)
// → In production: Managed Identity wins. No client secret ever touches your code.
var credential = new DefaultAzureCredential();
builder.Configuration.AddAzureKeyVault(
new Uri(kvUri),
credential,
new KeyVaultSecretManager()); // default: maps "MyApp--StripeKey" → "MyApp:StripeKey"
}
// ── Custom prefix manager: load only secrets belonging to this app ─────────
// Prevents one app from accidentally reading another app's secrets in a shared vault
public class AppPrefixKeyVaultSecretManager(string prefix)
: KeyVaultSecretManager
{
// Only load secrets whose name starts with "MyApp--" (e.g., "MyApp--Database--Host")
public override bool Load(SecretProperties secret) =>
secret.Name.StartsWith(prefix + "--", StringComparison.OrdinalIgnoreCase);
// Strip the prefix: "MyApp--Database--Host" → "Database:Host"
public override string GetKey(KeyVaultSecret secret) =>
secret.Name[prefix.Length..].TrimStart('-').Replace("--", ":");
}
// Use the prefixed manager:
builder.Configuration.AddAzureKeyVault(
new Uri(kvUri),
credential,
new AppPrefixKeyVaultSecretManager("MyApp"));
// ── Secret naming convention in Key Vault ─────────────────────────────────
// Key Vault secret names cannot contain colons — use double-dash as separator
// Key Vault name → IConfiguration key
// MyApp--Database--Host → Database:Host
// MyApp--Stripe--Key → Stripe:Key
// MyApp--Jwt--SigningKey → Jwt:SigningKey
// ── Refresh: reload secrets without restarting ────────────────────────────
// For secrets that rotate (e.g., database passwords with AAD auth), use periodic reload:
builder.Configuration.AddAzureKeyVault(
new Uri(kvUri),
credential,
new AzureKeyVaultConfigurationOptions
{
// Re-read all secrets from Key Vault every 5 minutes
// IOptionsMonitor subscribers are notified automatically
ReloadInterval = TimeSpan.FromMinutes(5)
});
The AppPrefixKeyVaultSecretManager is not optional in a shared vault — it's a hard requirement. Without it, every application that reads from the vault can see every other application's secrets. Prefix all secrets with an application identifier and only load secrets that match your prefix. This is one line of configuration that eliminates an entire class of cross-application secret exposure.
Binding Secrets to Typed Options — The Right Way to Consume Configuration
Reading secrets with builder.Configuration["Stripe:SecretKey"] works but scatters magic strings through your codebase and makes configuration dependencies invisible to the DI container. Typed options fix both problems: configuration structure is validated at startup, dependencies are explicit in constructor signatures, and nothing in your application has a direct string-key dependency on the configuration system.
// ── Options classes — one per logical group of settings ───────────────────
public sealed class DatabaseOptions
{
public const string SectionName = "Database";
[Required]
public string ConnectionString { get; init; } = "";
[Range(1, 500)]
public int MaxPoolSize { get; init; } = 100;
public TimeSpan CommandTimeout { get; init; } = TimeSpan.FromSeconds(30);
}
public sealed class StripeOptions
{
public const string SectionName = "Stripe";
[Required]
public string SecretKey { get; init; } = "";
[Required]
public string WebhookSecret { get; init; } = "";
public string ApiVersion { get; init; } = "2024-12-18";
}
// ── Program.cs — register with validation on startup ──────────────────────
builder.Services
.AddOptions()
.BindConfiguration(DatabaseOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart(); // fail fast: throw at startup if required values are missing
// not at first use in a handler — that's too late
builder.Services
.AddOptions()
.BindConfiguration(StripeOptions.SectionName)
.ValidateDataAnnotations()
.ValidateOnStart();
// ── Consuming options in services ─────────────────────────────────────────
public class PaymentService(IOptions stripeOpts)
{
private readonly StripeOptions _stripe = stripeOpts.Value;
// IOptions — snapshot at startup, doesn't reflect runtime changes
// IOptionsSnapshot— re-reads per-request scope (scoped services)
// IOptionsMonitor — live updates via OnChange callback (singletons, Key Vault reload)
public async Task ChargeAsync(decimal amount, string token)
{
// _stripe.SecretKey is available here — injected, typed, validated
// Your code has zero knowledge of whether it came from User Secrets,
// an env var, or Key Vault
var client = new StripeClient(_stripe.SecretKey);
// ...
}
}
// ── Startup validation output (shown when ValidateOnStart fails) ───────────
// Unhandled exception. Microsoft.Extensions.Options.OptionsValidationException:
// DataAnnotation validation failed for 'StripeOptions':
// The field SecretKey is required.
//
// This is the intended behaviour — a mis-configured app should not start.
// It is far better to discover this in your deployment pipeline's health check
// than to discover it when a user tries to make a payment at 2am.
ValidateOnStart() is the most important call in this block. Without it, a missing required secret is silent until the first request that touches it — which might be midnight on a Saturday during a slow traffic period. With it, the application refuses to start, your deployment pipeline catches the failure during the health check probe, and the pod never enters the ready pool.
The Decision Matrix: Which One, When
Three approaches, three distinct roles. The matrix below makes the choice mechanical — answer the questions in order and you arrive at the right tool without deliberation.
// ── QUESTION 1: What environment is this running in? ──────────────────────
// Local developer machine
// → USE: User Secrets
// ✓ Outside project directory — cannot be committed
// ✓ Per-developer — no shared credentials
// ✓ Zero friction: dotnet user-secrets set once, works forever
// ✗ Not encrypted; not for shared CI runners
// CI/CD pipeline (GitHub Actions, Azure DevOps, Jenkins)
// → USE: Environment variables injected from the pipeline's secret store
// ✓ GitHub Actions Secrets / Azure DevOps Variable Groups / Vault
// ✓ Masked in logs automatically by the platform
// ✓ Scoped per repository, per environment
// ✗ Baked at pipeline run time — rotation requires re-run
// Container / Kubernetes (staging, production)
// → USE: Environment variables from Kubernetes Secrets OR Key Vault
// Simple stable secrets: Kubernetes Secrets → env vars
// Rotating secrets / audit requirements: Key Vault + reload interval
// Azure PaaS (App Service, Azure Functions, Container Apps)
// → USE: Azure Key Vault with Managed Identity
// ✓ Key Vault references in App Service config auto-refresh on rotation
// ✓ No credentials needed — Managed Identity handles auth
// ✓ Full audit log of every access
// ── QUESTION 2: Does this secret rotate without a redeploy? ───────────────
// No rotation / accepts restart on rotation
// → Environment variables are sufficient
// Yes — secret changes and app must pick it up live (e.g., managed DB creds)
// → Azure Key Vault with ReloadInterval
// + IOptionsMonitor with OnChange callback to recycle connections
// ── QUESTION 3: Do you have compliance or audit requirements? ─────────────
// No
// → Environment variables from Kubernetes Secrets or CI/CD store
// Yes (SOC 2, PCI-DSS, ISO 27001, internal security policy)
// → Azure Key Vault (or equivalent: AWS Secrets Manager, HashiCorp Vault)
// ✓ Every access logged with caller identity and timestamp
// ✓ Secret versioning — previous value retrievable for incident analysis
// ✓ Centralised rotation and expiry policies
// ✓ Access revocable per identity without changing the secret itself
// ── QUICK REFERENCE ───────────────────────────────────────────────────────
// Development → User Secrets
// CI/CD pipelines → Pipeline secret store → Environment variables
// Containers (simple) → Kubernetes Secrets → Environment variables
// Containers (rotating / audit) → Azure Key Vault + Managed Identity
// Azure PaaS → Azure Key Vault + Managed Identity (always)
The one rule that supersedes the entire matrix: never put a real secret in appsettings.json, regardless of environment. It's not about whether the file is .gitignored — it's that every other layer in the pipeline (build artefacts, Docker image layers, deployment logs) becomes a potential exposure vector the moment a secret touches that file. Use placeholder values in appsettings.json and layer real values in from any of the three sources above.