Secrets Management — Stop Putting Credentials in Your Code
Why secrets in code is catastrophic, the secrets management ladder from env vars to Azure Key Vault, secrets rotation, scanning git history, and what to do after an accidental commit.
Why This Is Catastrophic
In 2022, Samsung suffered a breach because source code containing AWS credentials was posted publicly to GitHub. In 2023, a major cloud provider's internal tools repository exposed API keys in git history — keys that were "deleted" from the latest commit but visible in git log.
The dangers of secrets in code:
- Git history is forever: deleting a file doesn't remove it from history. Anyone who clones the repo gets all past commits.
- Forks: public repos can be forked before the secret is discovered. Forks persist.
- Build logs: CI/CD systems often log environment variables on failures. Logs are frequently stored long-term and broadly accessible.
- Code reviews: secrets in PRs are seen by every reviewer and stored in PR history.
- Shared development machines:
.envfiles get synced to Dropbox, included in backups, or left on laptops.
Automated scanners continuously crawl GitHub, GitLab, and npm for leaked credentials. Time between public leak and first unauthorized use is measured in minutes, not days.
The Secrets Management Ladder
Move up this ladder as your application matures.
Level 1 — Environment Variables
Better than hardcoding. Available in every language and runtime.
// Read from environment
var connectionString = Environment.GetEnvironmentVariable("DB_CONNECTION_STRING")
?? throw new InvalidOperationException("DB_CONNECTION_STRING not set");Problems: environment variables are process-wide, appear in /proc/<pid>/environ on Linux, can be logged accidentally, and in containerized environments are visible to anyone who can exec into the container.
Level 2 — .env Files (with .gitignore)
For local development only. A .env file holds values that map to environment variables.
# .env — LOCAL DEVELOPMENT ONLY
DB_CONNECTION_STRING=Server=localhost;Database=myapp;...
JWT_SECRET=local-dev-secret-not-for-production
SENDGRID_API_KEY=SG.dev-key...# .gitignore — CRITICAL
.env
.env.local
.env.*.localThe .env file must never be committed. Verify with git status before every commit during onboarding.
Level 3 — User Secrets (ASP.NET Core)
Designed specifically for local development secrets in .NET. Stored outside the project directory (%APPDATA%\Microsoft\UserSecrets\ on Windows), so they can't be accidentally committed.
# Initialize
dotnet user-secrets init
# Set a secret
dotnet user-secrets set "ConnectionStrings:DefaultConnection" "Server=localhost..."
dotnet user-secrets set "Sendgrid:ApiKey" "SG.local..."// Program.cs — automatically included in Development environment
// builder.Configuration already includes user secrets in Development
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
var sendgridKey = builder.Configuration["Sendgrid:ApiKey"];User secrets are scoped to the project (by the UserSecretsId in the .csproj) and are never included in builds or source control.
Level 4 — Cloud Vault (Production Standard)
Azure Key Vault, AWS Secrets Manager, HashiCorp Vault. Secrets live in the vault. Applications authenticate to the vault and retrieve secrets at startup or on demand.
Azure Key Vault Setup
# Create Key Vault
az keyvault create --name myapp-kv --resource-group myapp-rg --location eastus
# Add secrets
az keyvault secret set --vault-name myapp-kv --name "ConnectionStrings--DefaultConnection" --value "Server=prod-db..."
az keyvault secret set --vault-name myapp-kv --name "Sendgrid--ApiKey" --value "SG.prod..."
# Grant access to the application's managed identity
az keyvault set-policy --name myapp-kv \
--object-id <app-managed-identity-object-id> \
--secret-permissions get listNote the -- separator: Key Vault secret names use -- to represent the : separator in ASP.NET Core configuration hierarchy.
ASP.NET Core Key Vault Config Provider
// Program.cs
using Azure.Identity;
using Azure.Extensions.AspNetCore.Configuration.Secrets;
var builder = WebApplication.CreateBuilder(args);
if (builder.Environment.IsProduction())
{
var keyVaultUri = new Uri($"https://myapp-kv.vault.azure.net/");
// DefaultAzureCredential tries multiple auth methods in order:
// 1. EnvironmentCredential (env vars)
// 2. WorkloadIdentityCredential (Kubernetes)
// 3. ManagedIdentityCredential (Azure VMs, App Service, ACI)
// 4. AzureCliCredential (local dev if logged into az CLI)
// Zero credential configuration in code — works locally and in production
builder.Configuration.AddAzureKeyVault(
keyVaultUri,
new DefaultAzureCredential()
);
}
// The rest of your config reads identically whether secrets come from
// user-secrets, env vars, or Key Vault
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");The config provider loads all secrets from the vault at startup and maps them into the standard configuration hierarchy. No code changes needed for different secret sources.
HashiCorp Vault Basics
// Using VaultSharp
var vaultClient = new VaultClient(new VaultClientSettings(
"https://vault.example.com:8200",
new AppRoleAuthMethodInfo(roleId, secretId)
));
var secret = await vaultClient.V1.Secrets.KeyValue.V2.ReadSecretAsync(
path: "myapp/production",
mountPoint: "secret"
);
var dbPassword = secret.Data.Data["db_password"].ToString();HashiCorp Vault is language- and cloud-agnostic, making it ideal for multi-cloud or on-premises environments.
Secrets Rotation
Short-lived credentials dramatically reduce blast radius. If a credential is compromised, it expires in minutes or hours rather than being valid forever.
Auto-rotating database credentials with Azure Key Vault:
// Resolve connection string per-request from Key Vault (with local caching)
// rather than loading once at startup
public class RotatingConnectionStringProvider
{
private readonly SecretClient _secretClient;
private CachedSecret? _cache;
public async Task<string> GetConnectionStringAsync()
{
if (_cache != null && _cache.ExpiresAt > DateTime.UtcNow.AddMinutes(5))
return _cache.Value;
var secret = await _secretClient.GetSecretAsync("ConnectionStrings--DefaultConnection");
_cache = new CachedSecret(
secret.Value.Value,
secret.Value.Properties.ExpiresOn?.UtcDateTime ?? DateTime.UtcNow.AddHours(1)
);
return _cache.Value;
}
}Azure Key Vault supports automatic secret rotation with Azure Functions triggers — when a secret is about to expire, a function rotates the credential and updates the vault.
Scanning for Secrets in Git
Pre-commit scanning — catch secrets before they're committed:
# Install git-secrets (AWS)
git secrets --install
git secrets --register-aws
# Or use truffleHog for pre-commit
pip install truffleHog
trufflehog git file://. --only-verifiedGitHub push protection — GitHub scans pushes for known secret patterns (API keys, tokens, connection strings) and blocks the push if found. Enable in repo Settings > Code Security.
Semgrep rules for secrets in CI:
# .github/workflows/security.yml
- name: Scan for secrets
uses: trufflesecurity/trufflehog@main
with:
path: ./
base: ${{ github.event.repository.default_branch }}
head: HEADWhat to Do After Accidentally Committing a Secret
The instinct is to delete the file and push again. This is not sufficient. The secret is in git history.
Immediate steps (do these in the first 5 minutes):
- Rotate the secret immediately — assume it's already been seen. Change the password, revoke the API key, regenerate the token. Do this before anything else.
- Check for unauthorized use — audit logs, unusual API activity, failed logins.
- Revoke access — disable the compromised credential in the service that issued it.
After securing the live credential:
- Rewrite git history using
git filter-repo(preferred overgit filter-branch):
pip install git-filter-repo
git filter-repo --path secrets.txt --invert-paths
# Or remove by string content:
git filter-repo --replace-text <(echo 'AKIAIOSFODNN7EXAMPLE==>REMOVED')- Force-push to all branches.
- Notify all collaborators — their local clones still have the old history.
- If public: contact GitHub/GitLab support to purge caches.
Critical: git history rewrite invalidates every clone. Team members must re-clone. This is disruptive — which is why prevention is so much better than remediation.
Never Log Secrets
This sounds obvious. It happens anyway.
// DANGEROUS — logs entire config object including secrets
_logger.LogInformation("Starting with config: {@Config}", configuration);
// DANGEROUS — exception messages can contain connection strings
catch (SqlException ex)
{
_logger.LogError("Database error: {Message}", ex.Message);
// ex.Message for some drivers includes the connection string
}
// SAFE — log only what you need, be explicit
_logger.LogInformation("Application starting. Environment: {Env}", environment);
// SAFE — log exception type and code, not message for connection errors
catch (SqlException ex)
{
_logger.LogError("Database connection failed. Code: {Code}", ex.Number);
}Audit your logging statements for anywhere configuration objects, HttpContext, or exception details are logged. Structured logging with {@Object} destructuring is especially risky — it serializes the entire object graph including nested secrets.
Checklist
- [ ] Zero hardcoded secrets in source code
- [ ]
.envandsecrets.jsonin.gitignore - [ ]
dotnet user-secretsfor local dev - [ ] Azure Key Vault (or equivalent) for all environments
- [ ]
DefaultAzureCredential— no service principal secrets in config - [ ] Pre-commit hook or CI scan for secret patterns
- [ ] GitHub push protection enabled
- [ ] Secrets rotation policy defined and implemented
- [ ] Runbook for responding to a leaked secret
- [ ] Audit logging statements for accidental secret exposure
Enjoyed this article?
Explore the Security & Compliance learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.