| name | secrets-management |
| description | 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 secret leaks, or reviewing credentials handling. |
| allowed-tools | Read, Glob, Grep, Task, Bash |
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@v4
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:password123@prod.db.example.com/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