Secrets Management
A secret is any sensitive credential that grants access to a resource: API keys, database passwords, TLS private keys, OAuth client secrets, and encryption keys. Poor secrets management is responsible for a large proportion of cloud breaches - secrets accidentally committed to Git, stored in plaintext environment variables, or embedded in container images are routinely harvested by automated scanners.
The Problem with Naive Secrets Handling
Section titled “The Problem with Naive Secrets Handling”Secrets in Code (The Most Common Mistake)
Section titled “Secrets in Code (The Most Common Mistake)”# ❌ WRONG - hardcoded in source codedb_password = "SuperSecret123"api_key = "sk-live-abcdefghijklmnop"# These get committed to Git → discoverable by:# - Anyone with access to the repo (current or historical)# - GitHub secret scanning bots# - Automated scanners when repo goes public# - git log, even after "deletion" (history preserved)
# Even after removing the secret, it remains in Git historygit log --all --full-history -- config.py # still shows old commitsgit show <commit-hash>:config.py # still shows the secretEnvironment Variables - Better, But Not Safe Enough
Section titled “Environment Variables - Better, But Not Safe Enough”# ✅ Better: use environment variablesexport DB_PASSWORD="SuperSecret123"python app.py
# ⚠️ But environment variables are still risky:# - Visible in /proc/PID/environ (any process on same host can read)# - Logged in CI/CD output if not careful# - Available to all child processes# - Often appear in error dumps, stack traces, debug logs# - Docker inspect exposes them: docker inspect <container># Check environment variables of a running processcat /proc/$(pgrep nginx)/environ | tr '\0' '\n' | grep -i pass
# Docker exposes env vars in inspect outputdocker inspect mycontainer | jq '.[].Config.Env'# Shows all environment variables set in the container.env Files - Hygiene Rules
Section titled “.env Files - Hygiene Rules”.env files store environment variables for local development. They must never be committed.
# ✅ Correct .env hygieneecho ".env" >> .gitignore # immediately; before any secrets are addedecho ".env.*" >> .gitignore # catch .env.local, .env.production, etc.echo "*.pem" >> .gitignore # private keysecho "*.key" >> .gitignore
# .env file formatDB_HOST=localhostDB_PORT=5432DB_USER=appuserDB_PASSWORD=dev_password_only_for_localAPI_KEY=dev-key-xxxx
# .env.example - commit this instead (no real values)DB_HOST=localhostDB_PORT=5432DB_USER=DB_PASSWORD=API_KEY=# Check if .env files have been accidentally committedgit ls-files | grep -E "\.env$|\.env\."
# Search the entire Git history for secrets (truffleHog)pip install truffleHogtrufflehog git file://. --since-commit HEAD~100
# Or with gitleaks (fast, configurable)gitleaks detect --source . --report-path gitleaks-report.jsongitleaks git --log-opts="--all" # scan entire git historySecret Scanning - Automated Detection
Section titled “Secret Scanning - Automated Detection”GitHub Secret Scanning
Section titled “GitHub Secret Scanning”GitHub automatically scans all public repos (and private repos in paid plans) for known secret formats:
# GitHub Advanced Security - enable in repo Settings# Detects: AWS keys, GCP service account keys, GitHub tokens, Stripe keys,# Slack tokens, Twilio credentials, and 200+ partner patterns
# View detected secrets:# GitHub repo → Security → Secret scanning alertsPre-commit Hooks
Section titled “Pre-commit Hooks”Stop secrets before they reach the repo:
# Install pre-commit frameworkpip install pre-commit
# .pre-commit-config.yaml in repo root: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']
# Install hookspre-commit install
# Run manuallypre-commit run --all-filesHashiCorp Vault - The Standard for Secrets Management
Section titled “HashiCorp Vault - The Standard for Secrets Management”Vault is a purpose-built secrets management tool that centralises storage, auditing, and access control for secrets.
Key capabilities: - Dynamic secrets (generate DB creds on-demand, auto-expire) - Secret leasing (every secret has a TTL; auto-revoked when expired) - Encryption as a service (encrypt data without exposing keys) - Full audit log of every secret read/write/lease - Fine-grained policies (who can read which secrets?) - Multiple auth methods (LDAP, Kubernetes, AWS IAM, GitHub, etc.)Vault Setup and Basics
Section titled “Vault Setup and Basics”# Install Vaultcurl -fsSL https://apt.releases.hashicorp.com/gpg | gpg --dearmor > /usr/share/keyrings/hashicorp.gpgecho "deb [signed-by=/usr/share/keyrings/hashicorp.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" > /etc/apt/sources.list.d/hashicorp.listapt update && apt install vault
# Start dev server (in-memory; for learning only)vault server -devexport VAULT_ADDR='http://127.0.0.1:8200'export VAULT_TOKEN='root' # dev mode root token
# Production: initialise and unsealvault operator init # generates unseal keys and initial root tokenvault operator unseal # unseal (requires 3 of 5 unseal keys by default)vault login <root-token>KV Secrets Engine (Key-Value Store)
Section titled “KV Secrets Engine (Key-Value Store)”# Enable KV secrets engine at path 'secret/'vault secrets enable -path=secret kv-v2
# Write a secretvault kv put secret/myapp/database \ password="db_secret_here" \ username="appuser"
# Read a secretvault kv get secret/myapp/databasevault kv get -field=password secret/myapp/database # just the value
# List secrets at a pathvault kv list secret/myapp/
# Update a specific field (creates new version)vault kv patch secret/myapp/database password="new_password"
# Get a specific versionvault kv get -version=1 secret/myapp/database
# Delete (soft delete - versions kept)vault kv delete secret/myapp/database
# Permanently destroy a versionvault kv destroy -versions=1 secret/myapp/database
# Retrieve secret in JSON (useful for scripting)vault kv get -format=json secret/myapp/database | jq -r '.data.data.password'Dynamic Secrets - Database
Section titled “Dynamic Secrets - Database”Dynamic secrets are generated on-demand and automatically expire. The application never knows a long-lived credential.
# Enable database secrets enginevault secrets enable database
# Configure a PostgreSQL connectionvault write database/config/mypostgres \ plugin_name=postgresql-database-plugin \ allowed_roles="app-role" \ connection_url="postgresql://{{username}}:{{password}}@localhost:5432/mydb" \ username="vault_admin" \ password="vault_admin_password"
# Create a role (defines what creds are created and their TTL)vault write database/roles/app-role \ db_name=mypostgres \ 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"
# Generate dynamic credentials (a new DB user is created each time)vault read database/creds/app-role# Returns: username=v-app-role-random123, password=A1b2C3d4, lease_duration=1h
# After 1 hour, Vault automatically revokes the credentials# After 24 hours maximum, even a renewed credential is revokedVault Policies - Access Control
Section titled “Vault Policies - Access Control”# Policies are written in HCL (HashiCorp Configuration Language)cat > app-policy.hcl << 'EOF'# Allow read-only access to myapp's secretspath "secret/data/myapp/*" { capabilities = ["read"]}
# Allow renewing tokens and leasespath "auth/token/renew-self" { capabilities = ["update"]}
# Allow the app to generate dynamic DB credspath "database/creds/app-role" { capabilities = ["read"]}EOF
# Write the policyvault policy write myapp-policy app-policy.hcl
# Create a token with this policyvault token create -policy=myapp-policy -ttl=8h
# List policiesvault policy listvault policy read myapp-policyVault in Applications
Section titled “Vault in Applications”# Python: read Vault secrets at runtimeimport hvacimport os
client = hvac.Client( url=os.environ['VAULT_ADDR'], token=os.environ['VAULT_TOKEN'] # token from environment, not code)
response = client.secrets.kv.v2.read_secret_version( path='myapp/database', mount_point='secret')db_password = response['data']['data']['password']# Shell: fetch secret in CI/CD scriptDB_PASS=$(vault kv get -field=password secret/myapp/database)export DB_PASS
# Or use the Vault agent (recommended for production)# vault-agent automatically authenticates and refreshes tokens# writes secrets to a temp file or environment variableAWS Secrets Manager
Section titled “AWS Secrets Manager”For AWS-hosted workloads, Secrets Manager is the managed alternative to Vault.
# AWS CLI: store a secretaws secretsmanager create-secret \ --name "myapp/database/password" \ --description "Production DB password" \ --secret-string '{"username":"appuser","password":"db_password_here"}'
# Retrieve a secretaws secretsmanager get-secret-value \ --secret-id "myapp/database/password" \ --query SecretString \ --output text
# Retrieve specific fieldaws secretsmanager get-secret-value \ --secret-id "myapp/database/password" \ --query SecretString \ --output text | python3 -c "import sys,json; print(json.load(sys.stdin)['password'])"
# Enable automatic rotation (every 30 days)aws secretsmanager rotate-secret \ --secret-id "myapp/database/password" \ --rotation-days 30
# List secretsaws secretsmanager list-secrets --query 'SecretList[].Name'# Python boto3: read secret at runtime (no hardcoded credentials)import boto3import json
def get_secret(secret_name): client = boto3.client('secretsmanager', region_name='us-east-1') # IAM role on the EC2/Lambda handles authentication - no keys in code response = client.get_secret_value(SecretId=secret_name) return json.loads(response['SecretString'])
secret = get_secret("myapp/database/password")db_password = secret['password']Secrets in CI/CD Pipelines
Section titled “Secrets in CI/CD Pipelines”GitHub Actions
Section titled “GitHub Actions”# Store secrets in GitHub: Settings → Secrets and variables → Actions# Then reference them as:
name: Deployon: [push]jobs: deploy: runs-on: ubuntu-latest steps: - name: Deploy to production env: DB_PASSWORD: ${{ secrets.DB_PASSWORD }} # injected from GitHub Secrets API_KEY: ${{ secrets.PRODUCTION_API_KEY }} run: | # ⚠️ Never echo secrets - this leaks them to logs echo "Deploying..." # OK # echo "$DB_PASSWORD" # ❌ would leak to logs ./deploy.sh
# Fetch from Vault in CI (using Vault GitHub auth) - name: Import secrets from Vault uses: hashicorp/vault-action@v2 with: url: https://vault.example.com method: github githubToken: ${{ secrets.VAULT_GITHUB_TOKEN }} secrets: | secret/data/myapp/database password | DB_PASSWORD ; secret/data/myapp/api key | API_KEYCommon CI/CD Mistakes
Section titled “Common CI/CD Mistakes”# ❌ Secrets visible in runner environmentenv | grep -i "secret\|key\|password\|token" # any process can read these
# ❌ Secrets in artifact archives / build outputsdocker build --build-arg API_KEY=$API_KEY . # embedded in image layer!docker history myimage # reveals build args
# ✅ Correct: use build secrets (never stored in image)docker build --secret id=api_key,src=./api_key.txt .# In Dockerfile: RUN --mount=type=secret,id=api_key cat /run/secrets/api_key
# ✅ Correct: multi-stage build to strip secretsFROM build AS builderRUN --mount=type=secret,id=npm_token \ NPM_TOKEN=$(cat /run/secrets/npm_token) npm installFROM node:alpineCOPY --from=builder /app/node_modules ./node_modules # secrets not copiedComparison: Secrets Storage Options
Section titled “Comparison: Secrets Storage Options”| Option | Security | Auditability | Ease | Use for |
|---|---|---|---|---|
| Hardcoded in code | ❌ None | ❌ None | ✅ Easy | Never |
| Plaintext .env file | ⚠️ Low | ❌ None | ✅ Easy | Local dev only |
| OS environment variables | ⚠️ Medium | ❌ Low | ✅ Easy | Non-sensitive configs |
| CI/CD built-in secrets | ✅ Good | ✅ Medium | ✅ Easy | CI/CD pipeline secrets |
| Cloud secrets manager (AWS/GCP/Azure) | ✅✅ High | ✅✅ High | ✅ Medium | Cloud-native apps |
| HashiCorp Vault | ✅✅ High | ✅✅ High | ⚠️ Complex | Multi-cloud, dynamic secrets |
| Hardware security module (HSM) | ✅✅✅ Highest | ✅✅ High | ❌ Complex | Signing keys, root CA keys, PCI |
Secret Rotation
Section titled “Secret Rotation”Every secret should have a defined rotation schedule. Treat rotation as a normal operation, not an emergency.
# Audit: find long-lived AWS access keysaws iam generate-credential-reportaws iam get-credential-report --query 'Content' --output text | base64 -d | \ python3 -c "import sys, csvreader = csv.DictReader(sys.stdin)for row in reader: if row.get('access_key_1_active') == 'true': print(f\"{row['user']}: key created {row['access_key_1_last_rotated']}\")"
# Rotation best practices:# - API keys: rotate every 90 days# - Service account passwords: rotate every 30 days# - TLS certs: rotate before expiry (Let's Encrypt: 90 day, auto-renewal)# - GPG/SSH private keys: rotate when personnel changes or compromise suspected# - Database passwords: rotate via dynamic secrets (then TTL is the rotation)
# Vault: manually revoke a lease (before TTL expires)vault lease revoke database/creds/app-role/LEASE_IDvault lease revoke -prefix database/creds/app-role/ # revoke all for this role