Managing secrets in .NET
I've seen database passwords hardcoded in appsettings.json, API keys committed to Git, and connection strings sitting in plain text across multiple environment configs. Managing secrets in .NET isn't optional—it's the difference between a system you can sleep on and one waiting for a breach. Let me walk you through how I handle this across local development, CI/CD pipelines, and production at scale.
Why secrets in appsettings.json fail
When you store secrets in appsettings.json, you've already lost. Here's why:
- Git history is forever—even if you delete the file later, the secret lives in every clone and backup.
- appsettings.json gets checked into version control by default in most project templates.
- Anyone with repository access (including former team members) can read production secrets.
- You can't rotate secrets without code changes and redeploys.
I've inherited codebases where the first thing I did was rotate every single secret because I couldn't trust who'd seen what. Don't be that team.
Local development: dotnet user-secrets
For local work, .NET provides User Secrets Manager. It stores secrets outside your project directory—on Windows in %APPDATA%\Microsoft\UserSecrets, on macOS/Linux in ~/.microsoft/usersecrets. The file is unencrypted but isolated from your repo.
Here's the workflow. First, initialize secrets for your project:
dotnet user-secrets initThis creates a UserSecretsId in your .csproj file. Then set individual secrets:
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost;Database=mydb;User Id=sa;Password=YourActualPassword"
dotnet user-secrets set "ApiKeys:ExternalService" "sk_live_your_real_api_key"
dotnet user-secrets set "JwtSecret" "your-256-bit-secret-key-here"Your appsettings.json stays clean and committed:
{
"ConnectionStrings": {
"DefaultConnection": "Server=localhost;Database=mydb;User Id=sa;Password=****"
},
"ApiKeys": {
"ExternalService": "sk_live_****"
},
"JwtSecret": "****"
}In Startup or Program.cs, the configuration system automatically merges user secrets with your appsettings:
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsDevelopment())
{
builder.Configuration.AddUserSecrets();
}
builder.Services.Configure<DatabaseOptions>(builder.Configuration.GetSection("ConnectionStrings"));
builder.Services.Configure<ApiKeyOptions>(builder.Configuration.GetSection("ApiKeys"));The key benefit: you can list secrets with dotnet user-secrets list, remove them individually, or wipe everything for a fresh start. Each developer has their own isolated secrets that never touch Git.
CI/CD pipelines: secret variables
When your pipeline runs tests or builds artifacts, it needs secrets—but not hardcoded. Every CI/CD platform (GitHub Actions, Azure Pipelines, GitLab CI, Jenkins) supports encrypted secret variables that are injected at runtime.
Here's a GitHub Actions example:
name: Build and Test
on: [push, pull_request]
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: '8.0'
- name: Restore dependencies
run: dotnet restore
- name: Build
run: dotnet build --configuration Release --no-restore
- name: Run tests
run: dotnet test --configuration Release --no-build
env:
ConnectionStrings__DefaultConnection: ${{ secrets.DB_CONNECTION_STRING }}
ApiKeys__ExternalService: ${{ secrets.EXTERNAL_API_KEY }}
JwtSecret: ${{ secrets.JWT_SECRET }}Each secret is stored encrypted in your repository settings. GitHub doesn't log them, masks them in output, and only injects them into steps that explicitly request them. When a pull request comes from a fork, secrets aren't available—a security boundary worth respecting.
The pattern is consistent across platforms: store encrypted values, reference them by name in your workflow, and pass them as environment variables to your build commands. Your .NET app reads them through the standard IConfiguration interface—no changes needed.
Production: Key Vault or AWS Secrets Manager
Local and CI/CD are staging grounds. Production secrets live in managed vaults where you control who accesses them, audit every read, and rotate keys without touching code.
Azure Key Vault
If you're on Azure, Key Vault is the native choice. Install the NuGet package:
dotnet add package Azure.Identity
dotnet add package Azure.Security.KeyVault.Secrets
dotnet add package Azure.Extensions.AspNetCore.Configuration.SecretsThen wire it into your configuration during app startup:
var builder = WebApplication.CreateBuilder(args);
var keyVaultUrl = new Uri(builder.Configuration["KeyVault:Uri"]);
var credential = new DefaultAzureCredential();
builder.Configuration.AddAzureKeyVault(
keyVaultUrl,
credential,
new KeyVaultSecretManager());
var app = builder.Build();Your App Service or Container Instance authenticates to Key Vault using Managed Identity—no credentials to store. When your app starts, it pulls secrets on demand. You rotate keys in the vault; the app picks up changes without redeployment (with caching considerations).
AWS Secrets Manager
On AWS, Secrets Manager is the equivalent. Use the .NET SDK:
dotnet add package AWSSDK.SecretsManagerLoad secrets during startup:
var builder = WebApplication.CreateBuilder(args);
var secretsClient = new AmazonSecretsManagerClient(RegionEndpoint.USEast1);
try
{
var secretRequest = new GetSecretValueRequest
{
SecretId = "prod/myapp/database"
};
var secretResponse = await secretsClient.GetSecretValueAsync(secretRequest);
var secretString = secretResponse.SecretString;
var secrets = JsonSerializer.Deserialize<Dictionary<string, string>>(secretString);
foreach (var kvp in secrets)
{
builder.Configuration[kvp.Key] = kvp.Value;
}
}
catch (Exception ex)
{
// Log and fail fast if secrets aren't available
throw;
}
var app = builder.Build();Your EC2 instance or ECS task role has permissions to read from Secrets Manager. Secrets are encrypted at rest and in transit. You can rotate them through the AWS console without touching your application code.
Rotating keys and auditing access
Storing secrets safely is half the battle. The other half is knowing when and how to rotate them, and who's accessing them.
Rotation strategy
For API keys and tokens, I rotate on a schedule—typically every 90 days for critical credentials. Some services (like database passwords) should rotate more frequently. Here's the process:
- Generate a new secret in your vault (Key Vault or Secrets Manager).
- Update your application configuration to use the new value.
- Test thoroughly in staging.
- Deploy to production.
- Wait 24-48 hours to ensure the new value is stable.
- Revoke the old secret in your external service (e.g., tell your API provider to disable the old key).
Some services support key versioning. AWS Secrets Manager lets you store multiple versions and switch between them seamlessly. Azure Key Vault supports secret versions with timestamps. Use this—it gives you a rollback path if rotation fails.
Auditing
Both Key Vault and Secrets Manager log all access. Enable this logging to a central store—Azure Monitor for Key Vault, CloudTrail for AWS Secrets Manager. Set up alerts when someone accesses production secrets outside normal patterns.
In Key Vault, audit logs show who accessed what and when. If a developer leaves, you can verify no one used their credentials to read secrets after their last day. In AWS, CloudTrail records GetSecretValue calls with the principal's identity and timestamp.
I also restrict access at the IAM level. In production, only your application's service account can read secrets. Developers get read access in staging only. This principle of least privilege means a compromised developer machine can't exfiltrate production secrets.
Common mistakes
- Committing appsettings.Production.json to Git. Even if you ignore it later, it's in history. Use environment-specific vaults instead, and keep all appsettings files generic templates in version control.
- Hardcoding Key Vault or Secrets Manager URLs. These should also come from configuration, pulled from a safe place (environment variable, ARM template, Terraform).
- Not caching secrets locally. Calling Secrets Manager on every request is slow and expensive. Cache for 5-15 minutes in memory. If the vault is unreachable, fall back to the cached value rather than crashing.
- Logging secrets accidentally. If an exception includes a connection string, it might end up in application logs. Use structured logging and explicitly exclude sensitive fields.
- Forgetting to rotate. Set calendar reminders or use automated rotation features where available. AWS Secrets Manager can automatically rotate certain services; use this when possible.
- Over-permissioning vault access. If your whole team can manage production secrets, rotation and audit lose their meaning. Restrict to senior engineers or automated deployment accounts.
Key takeaways
- Never store secrets in appsettings.json or any version-controlled file. Once in Git history, it's compromised forever.
- Use
dotnet user-secretsfor local development, isolating secrets from your repository entirely. - Store secrets in CI/CD pipeline variables (GitHub Actions, Azure Pipelines, etc.), injected as environment variables at build time.
- Use managed vaults in production—Azure Key Vault or AWS Secrets Manager—with Managed Identity for authentication and no hardcoded credentials.
- Rotate critical secrets on a schedule (90 days for API keys, more frequently for passwords) and always test rotation in staging first.
- Enable audit logging on your vaults. Know who accessed what and when. Alert on unusual access patterns.
- Follow least privilege: only the application service account reads production secrets. Developers get staging access only.
- Cache secrets locally to reduce latency and vault load, but have a fallback strategy if the vault becomes unreachable.






Comments
Loading comments…