secrets-management
Comprehensive guidance for secure secrets management including storage solutions (Vault, AWS Secrets Manager, Azure Key Vault), environment variables, secret rotation, scanning tools, and CI/CD pipeline security. Use when implementing secrets storage, configuring secret rotation, preventing secre...
SKILL.md
Full skill instructions
Secrets Management
Comprehensive guidance for securely storing, accessing, rotating, and protecting secrets.
When to Use This Skill
Use this skill when:
- Choosing a secrets management solution
- Implementing secret rotation
- Preventing secrets in source code
- Configuring CI/CD pipeline secrets
- Setting up secrets scanning
- Reviewing credentials handling
- Migrating from insecure secret storage
Secrets Management Solutions
Comparison Matrix
| Solution | Self-Hosted | Cloud | Dynamic Secrets | Rotation | Cost |
|---|---|---|---|---|---|
| HashiCorp Vault | ✅ | ✅ | ✅ | ✅ | Free (OSS) / $$ |
| AWS Secrets Manager | ❌ | ✅ | ❌ | ✅ | $ |
| Azure Key Vault | ❌ | ✅ | ❌ | ✅ | $ |
| Google Secret Manager | ❌ | ✅ | ❌ | ✅ | $ |
| Doppler | ❌ | ✅ | ❌ | ❌ | $$ |
| Environment Variables | ✅ | ✅ | ❌ | Manual | Free |
When to Use What
| Use Case | Recommended Solution |
|---|---|
| Enterprise, multi-cloud | HashiCorp Vault |
| AWS-native applications | AWS Secrets Manager |
| Azure-native applications | Azure Key Vault |
| GCP-native applications | Google Secret Manager |
| Simple applications | Environment variables |
| Development | .env files (never commit!) |
HashiCorp Vault
Basic Usage
# Enable secrets engine
vault secrets enable -path=secret kv-v2
# Store a secret
vault kv put secret/myapp/database \
username="dbuser" \
password="supersecret"
# Read a secret
vault kv get secret/myapp/database
# Get specific field
vault kv get -field=password secret/myapp/database
Application Integration (C#)
using System.Text.Json;
using VaultSharp;
using VaultSharp.V1.AuthMethods.Token;
/// <summary>
/// HashiCorp Vault client for secrets retrieval.
/// </summary>
public sealed class VaultClient
{
private readonly IVaultClient _client;
public VaultClient(string url, string token)
{
var authMethod = new TokenAuthMethodInfo(token);
var settings = new VaultClientSettings(url, authMethod);
_client = new VaultSharp.VaultClient(settings);
}
/// <summary>
/// Get a secret from Vault KV v2.
/// </summary>
public async Task<string> GetSecretAsync(string path, string key, CancellationToken cancellationToken = default)
{
var secret = await _client.V1.Secrets.KeyValue.V2.ReadSecretAsync(path: path);
return secret.Data.Data[key].ToString()!;
}
/// <summary>
/// Get database credentials.
/// </summary>
public async Task<DatabaseCredentials> GetDatabaseCredentialsAsync(CancellationToken cancellationToken = default)
{
return new DatabaseCredentials(
Username: await GetSecretAsync("myapp/database", "username", cancellationToken),
Password: await GetSecretAsync("myapp/database", "password", cancellationToken)
);
}
}
public sealed record DatabaseCredentials(string Username, string Password);
// Usage
var vault = new VaultClient(
url: Environment.GetEnvironmentVariable("VAULT_ADDR")!,
token: Environment.GetEnvironmentVariable("VAULT_TOKEN")!
);
var dbCreds = await vault.GetDatabaseCredentialsAsync();
Dynamic Database Credentials
# Enable database secrets engine
vault secrets enable database
# Configure PostgreSQL connection
vault write database/config/mydb \
plugin_name=postgresql-database-plugin \
connection_url="postgresql://{{username}}:{{password}}@localhost:5432/mydb" \
allowed_roles="readonly,readwrite" \
username="vault" \
password="vault-password"
# Create a role
vault write database/roles/readonly \
db_name=mydb \
creation_statements="CREATE ROLE \"{{name}}\" WITH LOGIN PASSWORD '{{password}}' VALID UNTIL '{{expiration}}'; GRANT SELECT ON ALL TABLES IN SCHEMA public TO \"{{name}}\";" \
default_ttl="1h" \
max_ttl="24h"
# Get dynamic credentials
vault read database/creds/readonly
# Returns: username=v-token-readonly-xxx, password=xxx, lease_id=xxx
For detailed Vault patterns: See Vault Patterns Reference
AWS Secrets Manager
Store and Retrieve Secrets
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using System.Text.Json;
/// <summary>
/// AWS Secrets Manager client.
/// </summary>
public sealed class AwsSecretsClient(IAmazonSecretsManager client)
{
/// <summary>
/// Retrieve secret from AWS Secrets Manager.
/// </summary>
public async Task<T> GetSecretAsync<T>(string secretName, CancellationToken cancellationToken = default)
{
var response = await client.GetSecretValueAsync(
new GetSecretValueRequest { SecretId = secretName },
cancellationToken
);
return JsonSerializer.Deserialize<T>(response.SecretString)!;
}
}
// Usage with DI
public sealed record DbCredentials(string Username, string Password);
// In Startup/Program.cs
services.AddAWSService<IAmazonSecretsManager>();
services.AddSingleton<AwsSecretsClient>();
// In application code
var dbCreds = await secretsClient.GetSecretAsync<DbCredentials>("prod/myapp/database");
// Returns: DbCredentials { Username = "dbuser", Password = "secret" }
Automatic Rotation
using Amazon.SecretsManager;
using Amazon.SecretsManager.Model;
using System.Text.Json;
/// <summary>
/// Create secret with automatic rotation enabled.
/// </summary>
public static async Task CreateSecretWithRotationAsync(
IAmazonSecretsManager client,
string secretName,
object secretValue,
string rotationLambdaArn,
int rotationDays = 30,
CancellationToken cancellationToken = default)
{
// Create the secret
await client.CreateSecretAsync(new CreateSecretRequest
{
Name = secretName,
SecretString = JsonSerializer.Serialize(secretValue)
}, cancellationToken);
// Enable rotation (requires Lambda function)
await client.RotateSecretAsync(new RotateSecretRequest
{
SecretId = secretName,
RotationLambdaARN = rotationLambdaArn,
RotationRules = new RotationRulesType
{
AutomaticallyAfterDays = rotationDays
}
}, cancellationToken);
}
Environment Variables
Best Practices
# Set environment variables (not in code!)
export DATABASE_URL="postgresql://user:pass@localhost/db"
export API_KEY="sk_live_xxx"
# In systemd service file
[Service]
Environment="DATABASE_URL=postgresql://user:pass@localhost/db"
EnvironmentFile=/etc/myapp/secrets.env
# In Docker
docker run -e DATABASE_URL="postgresql://..." myapp
# Or from file
docker run --env-file ./secrets.env myapp
# In Kubernetes
kubectl create secret generic myapp-secrets \
--from-literal=DATABASE_URL="postgresql://..." \
--from-literal=API_KEY="sk_live_xxx"
Loading in Application
using Microsoft.Extensions.Configuration;
/// <summary>
/// Application configuration loaded from environment variables.
/// </summary>
public sealed class AppConfig
{
public required string DatabaseUrl { get; init; }
public required string ApiKey { get; init; }
public bool Debug { get; init; }
}
// In Program.cs or Startup.cs
var configuration = new ConfigurationBuilder()
.AddEnvironmentVariables()
.AddUserSecrets<Program>(optional: true) // For development
.Build();
// Bind to strongly-typed config
services.Configure<AppConfig>(options =>
{
options.DatabaseUrl = configuration["DATABASE_URL"]
?? throw new InvalidOperationException("DATABASE_URL is required");
options.ApiKey = configuration["API_KEY"]
?? throw new InvalidOperationException("API_KEY is required");
options.Debug = bool.TryParse(configuration["DEBUG"], out var debug) && debug;
});
// Or use options pattern
services.AddOptions<AppConfig>()
.Bind(configuration.GetSection("App"))
.ValidateDataAnnotations()
.ValidateOnStart();
// In application code
public class MyService(IOptions<AppConfig> config)
{
private readonly AppConfig _config = config.Value;
}
.env File Security
# .env (NEVER commit this!)
DATABASE_URL=postgresql://user:pass@localhost/db
API_KEY=sk_live_xxx
# .env.example (commit this as template)
DATABASE_URL=postgresql://user:pass@localhost/db
API_KEY=your-api-key-here
# .gitignore - ALWAYS include
.env
.env.local
.env.*.local
*.pem
*.key
secrets/
Secret Rotation
Rotation Strategy
using System.Security.Cryptography;
/// <summary>
/// Secret rotation with overlap period for zero-downtime rotation.
/// </summary>
public sealed class SecretRotator(ISecretsStore secrets, INotificationClient notifications)
{
private static readonly TimeSpan GracePeriod = TimeSpan.FromHours(24);
/// <summary>
/// Rotate an API key with overlap period.
/// </summary>
public async Task<string> RotateApiKeyAsync(string keyName, CancellationToken cancellationToken = default)
{
// 1. Generate new key
var newKey = Convert.ToBase64String(RandomNumberGenerator.GetBytes(32))
.Replace('+', '-').Replace('/', '_').TrimEnd('=');
// 2. Store new key as pending
await secrets.StoreAsync($"{keyName}_pending", newKey, cancellationToken);
// 3. Update primary key (old key still valid)
var oldKey = await secrets.GetAsync(keyName, cancellationToken);
await secrets.StoreAsync($"{keyName}_old", oldKey, cancellationToken);
await secrets.StoreAsync(keyName, newKey, cancellationToken);
// 4. Notify dependent services
await notifications.SendAsync(
$"API key {keyName} rotated. Update your configuration.",
cancellationToken
);
// 5. Schedule old key deletion (grace period)
await secrets.ScheduleDeletionAsync($"{keyName}_old", GracePeriod, cancellationToken);
return newKey;
}
/// <summary>
/// Accept both old and new keys during rotation.
/// </summary>
public async Task<bool> ValidateDuringRotationAsync(string keyName, string providedKey, CancellationToken cancellationToken = default)
{
var current = await secrets.GetAsync(keyName, cancellationToken);
if (CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(providedKey),
System.Text.Encoding.UTF8.GetBytes(current)))
{
return true;
}
var old = await secrets.GetOrDefaultAsync($"{keyName}_old", cancellationToken);
if (old is not null && CryptographicOperations.FixedTimeEquals(
System.Text.Encoding.UTF8.GetBytes(providedKey),
System.Text.Encoding.UTF8.GetBytes(old)))
{
return true;
}
return false;
}
}
// Interfaces for secrets and notifications
public interface ISecretsStore
{
Task<string> GetAsync(string key, CancellationToken cancellationToken);
Task<string?> GetOrDefaultAsync(string key, CancellationToken cancellationToken);
Task StoreAsync(string key, string value, CancellationToken cancellationToken);
Task ScheduleDeletionAsync(string key, TimeSpan delay, CancellationToken cancellationToken);
}
public interface INotificationClient
{
Task SendAsync(string message, CancellationToken cancellationToken);
}
Rotation Timeline
Day 0: Generate new key, deploy to secrets manager
├── Old key: ACTIVE
└── New key: PENDING
Day 1: Update applications to use new key
├── Old key: ACTIVE (grace period)
└── New key: ACTIVE
Day 7: Revoke old key
├── Old key: REVOKED
└── New key: ACTIVE
Secrets Scanning
Pre-commit Scanning
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: v8.18.0
hooks:
- id: gitleaks
- repo: https://github.com/Yelp/detect-secrets
rev: v1.4.0
hooks:
- id: detect-secrets
args: ['--baseline', '.secrets.baseline']
CI/CD Scanning
# GitHub Actions
name: Security Scan
on: [push, pull_request]
jobs:
secrets-scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
with:
fetch-depth: 0 # Full history for scanning
- name: Gitleaks scan
uses: gitleaks/gitleaks-action@v2
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: TruffleHog scan
uses: trufflesecurity/trufflehog@main
with:
path: ./
extra_args: --only-verified
Scanning Tools Comparison
| Tool | Strengths | Weaknesses |
|---|---|---|
| gitleaks | Fast, good regex patterns | May miss custom formats |
| TruffleHog | Verifies secrets are live | Slower, network calls |
| detect-secrets | Baseline support, plugins | More false positives |
| git-secrets | AWS patterns built-in | AWS-focused |
For detailed scanning setup: See Secrets Scanning Reference
CI/CD Pipeline Secrets
GitHub Actions
# Store secrets in repository settings
# Access via ${{ secrets.SECRET_NAME }}
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy
env:
DATABASE_URL: ${{ secrets.DATABASE_URL }}
API_KEY: ${{ secrets.API_KEY }}
run: |
# Secrets available as environment variables
./deploy.sh
# For OIDC authentication (preferred for cloud)
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
role-to-assume: arn:aws:iam::123456789:role/GitHubActionsRole
aws-region: us-east-1
GitLab CI
# Store in Settings > CI/CD > Variables
# Mark as "Masked" and "Protected"
deploy:
script:
- echo "Deploying with DB_PASSWORD=$DB_PASSWORD" # Never do this!
- ./deploy.sh
variables:
# Override for this job only
ENVIRONMENT: production
Best Practices for CI/CD Secrets
- Use OIDC when possible - No long-lived credentials
- Mask secrets in logs - CI systems should auto-mask
- Limit secret scope - Per-environment, per-branch
- Audit secret access - Who accessed what when
- Rotate regularly - Especially after team changes
Quick Decision Tree
Where should I store this secret?
- Production database credentials → Secrets Manager + rotation
- API keys for third-party services → Secrets Manager
- Encryption keys → HSM or Vault
- Development credentials → .env file (gitignored)
- CI/CD deployment credentials → CI/CD secrets + OIDC
- Inter-service authentication → Vault dynamic secrets
- User-submitted API keys → Encrypted database column
Anti-Patterns to Avoid
Never Do This
// WRONG: Hardcoded secrets
const string ApiKey = "sk_live_abc123";
const string DatabaseUrl = "postgresql://admin:[email protected]/app";
// WRONG: Secrets in appsettings.json (committed to git)
// {
// "Database": {
// "Password": "supersecret"
// }
// }
// WRONG: Secrets in Docker images
// COPY secrets.env /app/secrets.env
// WRONG: Logging secrets
_logger.LogInformation("Connecting with password: {Password}", password);
// WRONG: Secrets in error messages
throw new Exception($"Failed to connect: {connectionString}");
// WRONG: Secrets in URLs
await httpClient.GetAsync($"https://api.example.com?api_key={apiKey}");
Do This Instead
// RIGHT: Environment variables
var apiKey = Environment.GetEnvironmentVariable("API_KEY")
?? throw new InvalidOperationException("API_KEY not configured");
// RIGHT: Secrets manager
var apiKey = await secretsManager.GetSecretAsync("api-key");
// RIGHT: Configuration with User Secrets (dev) or Azure Key Vault (prod)
var apiKey = configuration["ApiKey"];
// RIGHT: Masked logging (use structured logging)
_logger.LogInformation("Connecting to database..."); // No credentials
// RIGHT: Generic error messages
throw new InvalidOperationException("Database connection failed"); // No details
// RIGHT: Secrets in headers (for APIs)
httpClient.DefaultRequestHeaders.Authorization =
new AuthenticationHeaderValue("Bearer", apiKey);
await httpClient.GetAsync("https://api.example.com");
Security Checklist
Storage
- No hardcoded secrets in source code
- Secrets stored in dedicated secrets manager
- Environment variables for configuration
- .env files gitignored
Access Control
- Least privilege access to secrets
- Audit logging enabled
- Secrets scoped to environments
- Regular access reviews
Rotation
- Rotation policy defined
- Automated rotation where possible
- Grace period for old secrets
- Notification on rotation
Detection
- Pre-commit hooks for secret scanning
- CI/CD pipeline scanning
- Git history scanning
- Regular repository audits
CI/CD
- Using CI platform's secrets management
- OIDC for cloud authentication
- Secrets masked in logs
- Limited secret scope
References
- Vault Patterns Reference - HashiCorp Vault deep dive
- Secrets Scanning Reference - Scanning tools setup
Related Skills
| Skill | Relationship |
|---|---|
cryptography | Encryption for secrets at rest |
devsecops-practices | CI/CD security integration |
authentication-patterns | API key and token management |
Version History
- v1.0.0 (2025-12-26): Initial release with Vault, cloud providers, rotation, scanning
Last Updated: 2025-12-26
