Connection strings in appsettings.json. API keys committed to source control. .env files uploaded to production servers. If any of these sound familiar, you have a secrets management problem. .NET provides a clear progression from development to production, and none of it involves storing secrets in your repository.
User Secrets for Development
The Secret Manager tool stores sensitive data outside your project directory during development. It's not encrypted — it's stored in plain text in your user profile — but it keeps secrets out of source control, which is the primary goal.
Initialise secrets for your project:
dotnet user-secrets init
dotnet user-secrets set "Database:ConnectionString" "Server=localhost;Database=myapp;User=sa;Password=dev-password"
dotnet user-secrets set "Stripe:ApiKey" "sk_test_abc123"
Secrets are stored at %APPDATA%\Microsoft\UserSecrets\<user_secrets_id>\secrets.json on Windows. The user_secrets_id is added to your .csproj:
<PropertyGroup>
<UserSecretsId>a1b2c3d4-e5f6-7890-abcd-ef1234567890</UserSecretsId>
</PropertyGroup>
In your application, user secrets are loaded automatically in the Development environment:
var builder = WebApplication.CreateBuilder(args);
// User secrets are already loaded — just access them
var connectionString = builder.Configuration["Database:ConnectionString"];
No code changes needed. The WebApplication.CreateBuilder method calls AddUserSecrets when ASPNETCORE_ENVIRONMENT is Development.
Environment Variables for Staging
Environment variables are a step up from user secrets. They work everywhere, they're not committed to source control, and most hosting platforms support them natively.
.NET's configuration system maps environment variables using __ (double underscore) as a hierarchy separator:
Database__ConnectionString=Server=staging-db;...
Stripe__ApiKey=sk_test_staging_key
These map directly to configuration keys:
var connectionString = builder.Configuration["Database:ConnectionString"];
Azure Key Vault for Production
For production, use a proper secret store. Azure Key Vault is the natural choice for .NET applications:
dotnet add package Azure.Extensions.AspNetCore.Configuration.Secrets
dotnet add package Azure.Identity
Add Key Vault as a configuration source:
var builder = WebApplication.CreateBuilder(args);
if (!builder.Environment.IsDevelopment())
{
var keyVaultUri = new Uri(builder.Configuration["KeyVault:Uri"]!);
builder.Configuration.AddAzureKeyVault(
keyVaultUri,
new DefaultAzureCredential());
}
DefaultAzureCredential automatically uses managed identity in Azure, Visual Studio credentials locally, and falls through a chain of credential types. This means your code works in both environments without changes.
Key Vault secret names use -- as a separator (since : isn't allowed), which maps to the usual : hierarchy:
Database--ConnectionString → Configuration["Database:ConnectionString"]
Filtering Secrets with Key Vault
Loading every secret from Key Vault at startup can be slow. Use a prefix convention and filter:
builder.Configuration.AddAzureKeyVault(
keyVaultUri,
new DefaultAzureCredential(),
new AzureKeyVaultConfigurationOptions
{
Manager = new PrefixKeyVaultSecretManager("MyApp")
});
public class PrefixKeyVaultSecretManager : KeyVaultSecretManager
{
private readonly string _prefix;
public PrefixKeyVaultSecretManager(string prefix)
{
_prefix = $"{prefix}-";
}
public override bool Load(SecretProperties properties)
{
return properties.Name.StartsWith(_prefix, StringComparison.OrdinalIgnoreCase);
}
public override string GetKey(KeyVaultSecret secret)
{
return secret.Name[_prefix.Length..]
.Replace("--", ConfigurationPath.KeyDelimiter);
}
}
The Options Pattern for Clean Access
Rather than scattering Configuration["Key"] calls throughout your code, bind secrets to strongly-typed options:
public class StripeOptions
{
public const string Section = "Stripe";
public string ApiKey { get; set; } = string.Empty;
public string WebhookSecret { get; set; } = string.Empty;
}
builder.Services.Configure<StripeOptions>(
builder.Configuration.GetSection(StripeOptions.Section));
Then inject them where needed:
public class PaymentService
{
private readonly StripeOptions _options;
public PaymentService(IOptions<StripeOptions> options)
{
_options = options.Value;
}
public void ProcessPayment()
{
var client = new StripeClient(_options.ApiKey);
// ...
}
}
Things to Avoid
Don't store secrets in appsettings.json. It gets committed to source control. Even appsettings.Production.json ends up in the repository.
Don't log configuration values at startup. A common debugging pattern that leaks secrets to log aggregators.
Don't hardcode fallback values. Code like Configuration["Key"] ?? "default-api-key" means a misconfiguration silently uses a known value instead of failing loudly.
Don't share Key Vault instances across trust boundaries. If Team A and Team B have different access levels, they should use different vaults.
Wrapping Up
The progression is clear: user secrets for development, environment variables for simple deployments, Azure Key Vault (or AWS Secrets Manager, or HashiCorp Vault) for production. The configuration system abstracts the source, so your application code never needs to know where secrets come from. Get the plumbing right once and secrets management becomes invisible.