# Confirm which secret was exposed
git log --all -p --no-color | grep -A2 -B2 "AKIA\|sk_live_\|SECRET"# Check if secret is in any open PRs
gh pr list --state open | whilereadpr; do
gh pr diff $(echo$pr | awk '{print $1}') | grep -E "AKIA|sk_live_" && echo"Found in PR: $pr"done
Step 2 — Identify Exposure Window
# Find first commit that introduced the secret
git log --all -p --no-color -- "*.env""*.json""*.yaml""*.ts""*.py" | \
grep -B 10 "THE_LEAKED_VALUE" | grep "^commit" | tail -1
# Get commit date
git show --format="%ci" COMMIT_HASH | head -1
# Check if secret appears in public repos (GitHub)
gh api search/code -X GET -f q="THE_LEAKED_VALUE" | jq '.total_count, .items[].html_url'
Step 3 — Rotate Credential
Per service — rotate immediately:
AWS: IAM console → delete access key → create new → update everywhere
Stripe: Dashboard → Developers → API keys → Roll key
GitHub PAT: Settings → Developer Settings → Personal access tokens → Revoke → Create new
DB password: ALTER USER app_user PASSWORD 'new-strong-password-here';
# Update secret manager (source of truth)# Then redeploy to pull new values# Vault KV v2
vault kv put secret/myapp/prod \
STRIPE_SECRET_KEY="sk_live_NEW..." \
APP_SECRET="new-secret-here"# AWS SSM
aws ssm put-parameter \
--name "/myapp/prod/STRIPE_SECRET_KEY" \
--value "sk_live_NEW..." \
--type"SecureString" \
--overwrite
# 1Password
op item edit "MyApp Prod" \
--field "STRIPE_SECRET_KEY[password]=sk_live_NEW..."# Doppler
doppler secrets set STRIPE_SECRET_KEY="sk_live_NEW..." --project myapp --config prod
Step 5 — Remove from Git History
# WARNING: rewrites history — coordinate with team first
git filter-repo --path-glob "*.env" --invert-paths
# Or remove specific string from all commits
git filter-repo --replace-text <(echo"LEAKED_VALUE==>REDACTED")
# Force push all branches (requires team coordination + force push permissions)
git push origin --force --all
# Notify all developers to re-clone
Step 6 — Verify
# Confirm secret no longer in history
git log --all -p | grep "LEAKED_VALUE" | wc -l # should be 0# Test new credentials work
curl -H "Authorization: Bearer $NEW_TOKEN" https://api.service.com/test
# Monitor for unauthorized usage of old credential (check service audit logs)
# Write (SecureString = encrypted with KMS)
aws ssm put-parameter \
--name "/myapp/prod/DATABASE_URL" \
--value "postgres://..." \
--type"SecureString" \
--key-id "alias/myapp-secrets"# Read all params for an app/env into shelleval $(aws ssm get-parameters-by-path \
--path "/myapp/prod/" \
--with-decryption \
--query "Parameters[*].[Name,Value]" \
--output text | \
awk '{split($1,a,"/"); print "export " a[length(a)] "=\"" $2 "\""}')
# In Node.js at startup# Use @aws-sdk/client-ssm to pull params before server starts
1Password CLI
# Authenticateeval $(op signin)
# Get a specific field
op read"op://MyVault/MyApp Prod/STRIPE_SECRET_KEY"# Export all fields from an item as env vars
op item get "MyApp Prod" --format json | \
jq -r '.fields[] | select(.value != null) | "export \(.label)=\"\(.value)\""' | \
grep -E "^export [A-Z_]+" | source /dev/stdin
# .env injection
op inject -i .env.tpl -o .env# .env.tpl uses {{ op://Vault/Item/field }} syntax
Doppler
# Setup
doppler setup # interactive: select project + config# Run any command with secrets injected
doppler run -- node server.js
doppler run -- npm run dev
# Export to .env (local dev only — never commit output)
doppler secrets download --no-file --format env > .env.local
# Pull specific secret
doppler secrets get DATABASE_URL --plain
# Sync to another environment
doppler secrets upload --project myapp --config staging < .env.staging.example
Environment Drift Detection
Check if staging and prod have the same set of keys (values may differ):
#!/bin/bash# scripts/check-env-drift.sh# Pull key names from both environments (not values)
STAGING_KEYS=$(doppler secrets --project myapp --config staging --format json 2>/dev/null | \
jq -r 'keys[]' | sort)
PROD_KEYS=$(doppler secrets --project myapp --config prod --format json 2>/dev/null | \
jq -r 'keys[]' | sort)
ONLY_IN_STAGING=$(comm -23 <(echo"$STAGING_KEYS") <(echo"$PROD_KEYS"))
ONLY_IN_PROD=$(comm -13 <(echo"$STAGING_KEYS") <(echo"$PROD_KEYS"))
if [ -n "$ONLY_IN_STAGING" ]; thenecho"Keys in STAGING but NOT in PROD:"echo"$ONLY_IN_STAGING" | sed 's/^/ /'fiif [ -n "$ONLY_IN_PROD" ]; thenecho"Keys in PROD but NOT in STAGING:"echo"$ONLY_IN_PROD" | sed 's/^/ /'fiif [ -z "$ONLY_IN_STAGING" ] && [ -z "$ONLY_IN_PROD" ]; thenecho"✅ No env drift detected — staging and prod have identical key sets"fi
Common Pitfalls
Committing .env instead of .env.example — add .env to .gitignore on day 1; use pre-commit hooks
Storing secrets in CI/CD logs — never echo $SECRET; mask vars in CI settings
Rotating only one place — secrets often appear in Heroku, Vercel, Docker, K8s, CI — update ALL
Forgetting to invalidate sessions after JWT secret rotation — all users will be logged out; communicate this
Using .env.example with real values — example files are public; strip everything sensitive
Not monitoring after rotation — watch audit logs for 24h after rotation to catch unauthorized old-credential use
Weak secrets — APP_SECRET=mysecret is not a secret. Use openssl rand -base64 32
Best Practices
Secret manager is source of truth — .env files are for local dev only; never in prod
Rotate on a schedule, not just after incidents — quarterly minimum for long-lived keys
Principle of least privilege — each service gets its own API key with minimal permissions
Audit access — log every secret read in Vault/SSM; alert on anomalous access
Never log secrets — add log scrubbing middleware that redacts known secret patterns
Use short-lived credentials — prefer OIDC/instance roles over long-lived access keys
Separate secrets per environment — never share a key between dev and prod
Document rotation runbooks — before an incident, not during one