Secrets Management — Handling Secrets in Production#
"A secret is anything that has entered Git history. Once it's in, it's there — deleting the commit doesn't delete the file, it burns the secret."
DB password, API key, TLS private key, OAuth client secret, kubeconfig token, S3 access key — all are secrets. This guide compares the modern stack that handles them "the right way" and gives you a decision tree.
🎯 Threat Model#
| Attacker profile | Scenario | What we shut down |
|---|---|---|
| Casual leak | New developer commits .env | gitleaks pre-commit + .gitignore |
| History dive | A departing employee pulls a token from old commits | Token rotation, BFG/git-filter-repo |
| Compromised CI | The CI runner gets taken over | OIDC, short-lived token |
| Compromised pod | RCE reads pod env vars | File mount tmpfs, no env |
| Insider | A broadly-privileged dev sees every secret | Per-team Vault namespace, audit |
| Cloud provider compromise | Attacker breaches the KMS provider | Encryption-at-rest + envelope encryption + per-region key |
🔑 Minimum principle: No secret should enter Git in clear text. No secret should be long-lived. No person should see a secret they don't need.
🪜 Maturity Levels#
| Level | State | Risk |
|---|---|---|
| L0 | .env in Git | Catastrophic — secret lives in commit history |
| L1 | .env in .gitignore, shared over Slack | High — no audit trail on sharing |
| L2 | Secret manager (Bitwarden/1Password) within the team | Medium — app still pulls via env var |
| L3 | Vault / Cloud Secrets Manager + manual inject | Good — rotation is manual |
| L4 | Vault + ESO + dynamic creds + audit log | Target — short-lived, auto-rotated |
| L5 | L4 + zero-trust + workload identity (SPIFFE) | Advanced — multi-cluster, multi-cloud |
🔍 Solution Comparison#
| Solution | Type | Best for | Limits |
|---|---|---|---|
| HashiCorp Vault | Self-hosted secret manager | Multi-cloud, on-prem, dynamic credentials | Operational overhead (HA, unseal, backup) |
| AWS Secrets Manager | Managed | Single-cloud AWS | Vendor lock-in, cross-region cost |
| GCP Secret Manager | Managed | Single-cloud GCP | Same |
| Azure Key Vault | Managed | Single-cloud Azure | Same |
| External Secrets Operator (ESO) | K8s controller | Mirrors the above into K8s | Not a backend itself |
| SOPS (Mozilla) | File encryption | Committing encrypted secrets in Git | Rotation manual, multi-recipient management is complex |
| Sealed Secrets (Bitnami) | K8s controller | GitOps + cluster-scoped encryption | K8s only, key compromise → all secrets must be rotated |
| AWS SSM Parameter Store | Managed (basic) | Simple config + secret, cheap | No rotation, weak version history |
| Doppler / Infisical | SaaS | Startup speed, multi-env UI | SaaS lock-in, requires compliance review |
🌳 Decision Tree#
START
│
├── Are you using K8s?
│ │
│ ├── YES → ESO + (Vault | AWS SM | GCP SM | Azure KV)
│ │ └── Is GitOps mandatory?
│ │ └── YES → SOPS for *configmap*, ESO for *secret*
│ │
│ └── NO → CI/CD + cloud-native secret manager
│
├── Any multi-cloud / on-prem?
│ │
│ └── YES → Vault (self-hosted instead of managed, single API)
│
├── Do you need compliance (SOC2, ISO27001, KVKK)?
│ │
│ └── YES → Vault Enterprise or cloud-native + audit log shipping
│
└── Zero budget, small team?
│
└── YES → SOPS + age key + cloud KMS (free tiers)
🛠️ HashiCorp Vault Setup (Production-Grade)#
Architecture#
┌──────────────────────────────────────┐
│ Vault HA Cluster │
│ (3 node, Raft storage backend) │
│ Auto-unseal: AWS KMS / GCP KMS │
│ TLS internal + external │
└──┬───────────────┬──────────────┬────┘
│ │ │
┌────▼────┐ ┌─────▼────┐ ┌────▼────┐
│ ESO │ │ CI/CD │ │ Apps │
│ K8s │ │ GitHub │ │ AppRole│
│ auth │ │ OIDC │ │ / JWT │
└─────────┘ └──────────┘ └─────────┘
Helm install (production values)#
# vault-values.yaml
server:
ha:
enabled: true
replicas: 3
raft:
enabled: true
setNodeId: true
config: |
ui = true
listener "tcp" {
tls_disable = 0
tls_cert_file = "/vault/userconfig/vault-tls/tls.crt"
tls_key_file = "/vault/userconfig/vault-tls/tls.key"
}
storage "raft" {
path = "/vault/data"
retry_join {
leader_api_addr = "https://vault-0.vault-internal:8200"
}
}
seal "awskms" {
region = "<AWS_REGION>"
kms_key_id = "<KMS_KEY_ID>"
}
resources:
requests: {cpu: 250m, memory: 256Mi}
limits: {cpu: 1000m, memory: 1Gi}
auditStorage:
enabled: true
size: 10Gi
helm install vault hashicorp/vault \
--namespace vault --create-namespace \
-f vault-values.yaml --version <CHART_VERSION>
First init + unseal#
# Init (only once!)
kubectl exec -n vault vault-0 -- vault operator init \
-key-shares=5 -key-threshold=3 -format=json > vault-init.json
# If you're doing auto-unseal with AWS KMS, unseal is already automatic;
# if there's no managed KMS (lab env):
kubectl exec -n vault vault-0 -- vault operator unseal <KEY_1>
kubectl exec -n vault vault-0 -- vault operator unseal <KEY_2>
kubectl exec -n vault vault-0 -- vault operator unseal <KEY_3>
# vault-init.json → VAULT: 5 unseal keys + root token.
# 5 keys → share with 5 different people. Root token → revoke as soon as possible.
🚨
vault-init.jsonMUST NOT enter Git. It must be shared across 5 physical/AirGap vaults. After using the root token, revoke it and create a separate admin policy for oncall.
Audit log#
vault audit enable file file_path=/vault/audit/audit.log
# or syslog → SIEM
vault audit enable syslog tag="vault" facility="AUTH"
🔑 Vault Secret Engines (most used)#
1. KV v2 (static secret)#
vault secrets enable -path=kv kv-v2
vault kv put kv/<APP>/db username=app password=<PWD>
vault kv get kv/<APP>/db
vault kv metadata get kv/<APP>/db # version history
2. Database secret engine (dynamic credentials)#
The most powerful feature — Vault creates a DB user on demand and deletes it at the end of the TTL.
vault secrets enable database
vault write database/config/postgres \
plugin_name=postgresql-database-plugin \
allowed_roles="readonly,readwrite" \
connection_url="postgresql://{{username}}:{{password}}@<DB_HOST>:5432/<DB_NAME>" \
username=<VAULT_DB_ADMIN> \
password=<VAULT_DB_ADMIN_PWD>
vault write database/roles/readonly \
db_name=postgres \
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"
# App gets a credential with TTL=1h
vault read database/creds/readonly
3. PKI (TLS certs)#
vault secrets enable -path=pki_int pki
vault write pki_int/issue/internal \
common_name="<SERVICE>.<NAMESPACE>.svc.cluster.local" \
ttl=72h
4. Transit (encryption-as-a-service)#
The app sends plaintext to Vault and gets encrypted bytes back. The key never leaves Vault.
vault secrets enable transit
vault write -f transit/keys/<APP>-key
# Encrypt
echo -n "card-no-1234" | base64 | \
vault write transit/encrypt/<APP>-key plaintext=-
# Decrypt
vault write transit/decrypt/<APP>-key ciphertext=<CIPHERTEXT>
🔑 If you have PCI/PII use transit or cloud KMS — the app never sees the key, and every decrypt call shows up in the audit log.
🔐 External Secrets Operator (K8s)#
Automatically produces a K8s Secret resource, sourced from Vault/AWS SM etc.
Setup#
helm install external-secrets external-secrets/external-secrets \
-n external-secrets --create-namespace
Vault auth: Kubernetes auth method#
# Enable in Vault
vault auth enable kubernetes
vault write auth/kubernetes/config \
kubernetes_host="https://<K8S_API>" \
token_reviewer_jwt="$(kubectl get secret <SA_TOKEN> -o jsonpath='{.data.token}' | base64 -d)" \
kubernetes_ca_cert=@/path/to/ca.crt
# Policy: <APP> can only read its own path
vault policy write <APP>-read - <<EOF
path "kv/data/<APP>/*" { capabilities = ["read"] }
EOF
# Role: K8s SA "<APP>-sa" → <APP>-read policy
vault write auth/kubernetes/role/<APP> \
bound_service_account_names="<APP>-sa" \
bound_service_account_namespaces="<NAMESPACE>" \
policies="<APP>-read" \
ttl="1h"
ClusterSecretStore + ExternalSecret#
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault
spec:
provider:
vault:
server: "https://vault.<DOMAIN>:8200"
path: "kv"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "<APP>"
serviceAccountRef:
name: "<APP>-sa"
namespace: "<NAMESPACE>"
---
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: <APP>-db
namespace: <NAMESPACE>
spec:
refreshInterval: "1h"
secretStoreRef:
name: vault
kind: ClusterSecretStore
target:
name: <APP>-db # this K8s secret is created
template:
type: Opaque
data:
DATABASE_URL: "postgresql://{{ .username }}:{{ .password }}@<DB_HOST>:5432/<DB>"
data:
- secretKey: username
remoteRef:
key: <APP>/db
property: username
- secretKey: password
remoteRef:
key: <APP>/db
property: password
🔑 The secret is pulled from Vault once an hour. If you rotate it in Vault, the K8s secret updates too. Restart the pod:
reloaderannotation or theStakater Reloadercontroller.
📦 SOPS — Encrypted Commit in Git#
If you're doing GitOps, ConfigMap/Secret manifests have to live in Git but must not be clear-text. SOPS solves this.
Setup#
.sops.yaml#
creation_rules:
- path_regex: \.(yaml|yml)$
encrypted_regex: '^(data|stringData)$' # ONLY the secret section is encrypted
age: |
age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p,
age1...
Create an encrypted secret#
cat > db-secret.yaml <<EOF
apiVersion: v1
kind: Secret
metadata:
name: db
namespace: <NAMESPACE>
type: Opaque
stringData:
username: app
password: <PWD>
EOF
sops -e -i db-secret.yaml
git add db-secret.yaml # encrypted — safe to commit
Decrypt in the cluster: helm-secrets or FluxCD SOPS support#
- ArgoCD:
argocd-vault-pluginor thehelm-secretsplugin - Flux:
decryption.provider: sopsnative support
⚠️ SOPS limitation: the age private key has to live somewhere. Usually you put it in Vault and pull it in CI → using SOPS together with Vault is the most robust flow.
🌱 Sealed Secrets (Bitnami)#
K8s-specific; the controller decrypts with a cluster-scoped public key.
# Encrypt
kubectl create secret generic db --dry-run=client \
--from-literal=password=<PWD> -o yaml | \
kubeseal --controller-namespace=kube-system -o yaml > db-sealed.yaml
git add db-sealed.yaml # safe
| ✅ | ❌ |
|---|---|
| Fast setup | Single key, disaster if lost |
| GitOps friendly | For multi-cluster, a separate key per cluster |
| Clean on the operator side | Rotation is complex (rotating the controller key → every secret must be re-sealed) |
🔐 Secret Management in CI/CD#
GitHub Actions: cloud auth via OIDC (NO long-lived key)#
permissions:
id-token: write # required for OIDC
contents: read
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: aws-actions/configure-aws-credentials@<VERSION>
with:
role-to-assume: arn:aws:iam::<ACCOUNT>:role/<ROLE>
aws-region: <REGION>
# NO access-key-id / secret-access-key
# AWS IAM trust relationship
{
"Version": "2012-10-17",
"Statement": [{
"Effect": "Allow",
"Principal": {"Federated": "arn:aws:iam::<ACCOUNT>:oidc-provider/token.actions.githubusercontent.com"},
"Action": "sts:AssumeRoleWithWebIdentity",
"Condition": {
"StringEquals": {"token.actions.githubusercontent.com:aud": "sts.amazonaws.com"},
"StringLike": {"token.actions.githubusercontent.com:sub": "repo:<ORG>/<REPO>:ref:refs/heads/main"}
}
}]
}
GitLab CI: ID token#
deploy:
id_tokens:
AWS_TOKEN:
aud: https://gitlab.example.com
script:
- aws sts assume-role-with-web-identity ...
🧹 When a Secret Leaks (Incident Playbook)#
⏱️ The clock is running. Once a leak is understood, seconds matter.
1. Rotate — IMMEDIATELY#
- DB password → set a new password, revoke the old one
- API key → revoke + reissue
- TLS private key → new cert, revoke the old cert (CRL/OCSP)
- IAM access key → deactivate + delete
2. Audit — who, when, what did they do?#
- CloudTrail / Cloud audit logs
- Vault audit log
- Database audit (postgres
pg_stat_activity)
3. Git history cleanup (surface operation, not root cause)#
# git-filter-repo (recommended)
pip install git-filter-repo
git filter-repo --path <FILE> --invert-paths
# or BFG
bfg --delete-files <FILE>
# force-push to remote
git push --force --all
git push --force --tags
⚠️ Important: Deleting the secret from Git alone is not enough. Assume the attacker already copied it. Rotation > git history cleanup. But clean the history anyway so a new person can't reuse it.
4. Root cause + postmortem#
- Why did the pre-commit hook miss it?
- Why wasn't it in the secret manager?
- Is developer training needed?
- See
11-SRE/Incident-Response.md
🔍 Detection — How Do You Know There's a Leak?#
Pre-commit (gitleaks)#
# .pre-commit-config.yaml
repos:
- repo: https://github.com/gitleaks/gitleaks
rev: <VERSION>
hooks:
- id: gitleaks
CI scan#
- name: Gitleaks scan
uses: gitleaks/gitleaks-action@<VERSION>
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
GitHub Secret Scanning (free, open to public repos)#
- Settings → Code security → Secret scanning
- Push protection: rejects a commit containing a secret
Cloud-native scanning#
- AWS Macie (scans S3 buckets for PII/secrets)
- Google DLP, Azure Purview
Vault audit anomaly#
# 100x normal reads in 24 hours → anomaly
in the vault audit log:
"request.operation":"read"
"auth.policies":["<APP>-read"]
count > <THRESHOLD>
🚫 Anti-Pattern Table#
| Anti-pattern | Why it's bad | Correct |
|---|---|---|
.env in Git | Commit history forever | .gitignore + .env.example |
| Secret as an env var into the pod | Shows up in ps, crash dumps, error logs | File mount tmpfs |
A single cluster-admin token across all of CI | Compromise → cluster down | OIDC + namespace-scoped |
| Vault root token in the app | Attacker reads every secret | AppRole / K8s auth + minimal policy |
| Secret never rotated | "Let's not change it before something breaks" | Quarterly rotation, dynamic creds |
| Prod secret in the test environment | Leak in test → prod compromise | Per-env secret, never shared |
| Sharing secrets over Slack/email | Stays in logs | 1Password share, expiring link |
| Direct access to Vault from the internet | Brute force / DDoS | Private network + bastion |
| No backup of the Sealed Secrets controller key | If the cluster dies, every secret is garbage | Off-cluster encrypted backup |
| No dynamic creds, static 6-month password | 6-month compromise window | TTL=1h dynamic creds |
No git secrets or gitleaks | New developer commits a secret | Pre-commit + CI scan mandatory |
📋 Minimum Hygiene Checklist#
[ ] .gitignore: .env, *.pem, *.key, *credentials*
[ ] gitleaks pre-commit hook on every developer machine
[ ] gitleaks/trufflehog runs on every PR in CI
[ ] No secret in Git history (you scanned retroactively)
[ ] GitHub Secret Scanning + Push Protection enabled
[ ] Production secrets in Vault/cloud SM, no plaintext in K8s Secret
[ ] etcd encryption-at-rest enabled (KMS provider)
[ ] Injected into apps via ESO or equivalent
[ ] DB credentials are dynamic (Vault DB engine or cloud IAM)
[ ] CI/CD: OIDC, no long-lived cloud key anywhere
[ ] Vault audit log → shipped to SIEM
[ ] Secret rotation policy written down + on the calendar
[ ] Incident playbook exists, team ran a drill
[ ] Transit encryption in PCI/PII areas (Vault Transit or KMS envelope)
[ ] Insider threat: per-team Vault namespace, no cross access
📚 References#
- HashiCorp Vault Documentation — vaultproject.io/docs
- External Secrets Operator — external-secrets.io
- SOPS + age — github.com/getsops/sops
- OWASP Secrets Management Cheat Sheet — cheatsheetseries.owasp.org
Kubernetes-Hardening.md— etcd encryption + RBAC sectionDevSecOps-Pipeline.md— pre-commit + CI scan section19-Compliance/(Phase 4) — KVKK (Turkey's Personal Data Protection Law, No. 6698) / GDPR secret-retention obligations
"The security of a secret is a product of discipline, not technology. The best Vault setup won't work with a culture that hasn't made 'don't commit secrets' a reflex for the team."
🎓 Learning Path: This document is used as a "read first" resource in the
D3module.