Secrets Management Without Tears
- lesson
- env-vars
- files
- hashicorp-vault
- k8s-secrets
- external-secrets
- rotation ---# Secrets Management Without Tears
Topics: env vars, files, HashiCorp Vault, K8s secrets, external-secrets, rotation Level: L1–L2 (Foundations → Operations) Time: 45–60 minutes Prerequisites: None
The Mission¶
You're reviewing a PR and find this:
Hardcoded in the source code. Committed to Git. In every clone, every fork, every backup,
forever. The password is compromised the moment it's pushed. Even if you delete it in the
next commit, git log remembers.
This lesson builds up from the worst practices to the best, level by level. Each level solves the previous level's problem.
Level 0: Hardcoded in Code (Never Do This)¶
# BAD — committed to Git, visible to everyone, lives forever
DATABASE_URL = "postgresql://admin:s3cr3t@db.example.com:5432/myapp"
Why it's terrible: source code is shared, versioned, cloned, and often public. Secrets in code are the #1 finding in security audits and the #1 cause of credential leaks on GitHub.
War Story: Uber's 2016 breach started with AWS credentials committed to a private GitHub repository. Attackers found them, accessed an S3 bucket with 57 million user records, and Uber paid $100K in hush money. Fine: $148 million.
Level 1: Environment Variables¶
Better than hardcoded — the secret isn't in source code. But environment variables are
visible in /proc/<pid>/environ, docker inspect, ps eww, and any process that reads
the environment.
# Anyone with access to the container can see all env vars
docker inspect mycontainer | jq '.[0].Config.Env'
# → ["DATABASE_URL=postgresql://admin:s3cr3t@..."]
# Or from inside
cat /proc/1/environ | tr '\0' '\n'
Level 2: Files with Restricted Permissions¶
# Secret in a file, readable only by the app user
echo "s3cr3t" > /etc/myapp/db_password
chmod 600 /etc/myapp/db_password
chown appuser:appuser /etc/myapp/db_password
Files with 600 permissions are safer than env vars — they're not in docker inspect or
/proc/environ. But they're still plaintext on disk, and anyone with root access can read
them.
In Docker, mount the secret as a file:
Level 3: Kubernetes Secrets¶
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
type: Opaque
data:
password: czNjcjN0 # base64-encoded (NOT encrypted)
# Mount as file in pod
containers:
- name: myapp
volumeMounts:
- name: db-creds
mountPath: /run/secrets
readOnly: true
volumes:
- name: db-creds
secret:
secretName: db-credentials
Gotcha: Kubernetes Secrets are base64-encoded, NOT encrypted. Anyone with
kubectl get secret -o yamlcan decode them. They're stored in etcd, which may or may not be encrypted at rest (depends on your cluster configuration). Enable etcd encryption:--encryption-provider-configon the API server.
Quick Check: Levels 0-3¶
Q: Your K8s Secret has password: czNjcjN0. Is this encrypted?
No. It's base64-encoded —
echo czNjcjN0 | base64 -d=s3cr3t. Anyone withkubectl get secret -o yamlcan decode it.
Q: Environment variables — are they safer than hardcoded passwords?
Marginally. They're not in Git, but they're visible via
docker inspect,/proc/PID/environ, andps eww. File mounts withchmod 600are safer.
Level 4: HashiCorp Vault (Dynamic Secrets)¶
Vault is a dedicated secrets management system. The key innovation: dynamic secrets. Instead of storing a static password, Vault creates temporary credentials on demand:
# App requests database credentials from Vault
vault read database/creds/my-role
# → username: v-app-my-role-abc123
# → password: randomized-password-xyz
# → lease_duration: 1h
# After 1 hour, the credentials expire automatically
# No manual rotation needed
The app gets fresh credentials every hour. If credentials are compromised, they expire on their own. No one knows the "real" database password — because there isn't one.
Level 5: External Secrets Operator (Kubernetes + Vault/AWS/GCP)¶
Bridge between Vault (or AWS Secrets Manager, GCP Secret Manager) and Kubernetes Secrets:
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: db-credentials
data:
- secretKey: password
remoteRef:
key: secret/data/myapp/database
property: password
The operator syncs secrets from Vault/AWS into Kubernetes Secrets automatically. The secret source of truth is Vault, not Kubernetes. Rotation happens in Vault and propagates to K8s.
Secret Rotation: The Part Everyone Skips¶
A secret that never rotates is a ticking time bomb. If it's compromised, the attacker has access forever. Rotation limits the damage window.
| Secret type | Rotation frequency | How to rotate |
|---|---|---|
| Database passwords | 30-90 days | Vault dynamic secrets (automatic) |
| API keys | 90 days | Generate new key, update references, revoke old |
| TLS certificates | 90 days (moving toward 47) | cert-manager (automatic) |
| SSH keys | 6-12 months | SSH certificates with short TTLs (automatic) |
| Encryption keys | 1 year | Key versioning (new key encrypts, old key decrypts) |
Flashcard Check¶
Q1: Environment variables are visible where?
/proc/<pid>/environ,docker inspect,ps eww. Anyone with container or process access can read them.
Q2: Kubernetes Secrets are encrypted, right?
No. Base64-encoded, not encrypted. Enable etcd encryption separately. Anyone with
kubectl get secret -o yamlcan decode them.
Q3: What are dynamic secrets?
Temporary credentials generated on demand (e.g., by Vault). They expire automatically after a TTL. No static password to rotate or leak.
Q4: External Secrets Operator — what does it do?
Syncs secrets from Vault/AWS/GCP into Kubernetes Secrets automatically. Source of truth stays in the secrets manager, K8s secrets are kept in sync.
Cheat Sheet¶
The Ladder¶
| Level | Where secrets live | Risk |
|---|---|---|
| 0: In code | Git history | Permanent compromise |
| 1: Env vars | Process environment | Visible via inspect/proc |
| 2: Files (600) | Filesystem | Root can read |
| 3: K8s Secrets | etcd (base64) | Not encrypted by default |
| 4: Vault | Encrypted, audited, dynamic | Vault itself becomes critical |
| 5: External Secrets | Vault → K8s (synced) | Best of both worlds |
Takeaways¶
-
Never commit secrets to Git. Even private repos. Even "just for now." Git history is forever.
-
Environment variables are not secure. They're visible via
docker inspectand/proc. Use file mounts with restricted permissions. -
Kubernetes Secrets are base64, not encrypted. Enable etcd encryption. Use RBAC to restrict who can read them.
-
Dynamic secrets are the gold standard. Vault generates temporary credentials that expire automatically. No rotation needed.
-
Rotate everything on a schedule. A never-rotated secret is a permanent access token for anyone who finds it.
Exercises¶
-
Prove environment variables are visible. Run a container with a "secret" env var:
docker run -d --name secret-test -e MY_SECRET=hunter2 nginx. Usedocker inspect secret-test | grep MY_SECRETto see the secret in plaintext. Then exec in and check/proc/1/environ:docker exec secret-test cat /proc/1/environ | tr '\0' '\n' | grep MY_SECRET. Clean up withdocker rm -f secret-test. This demonstrates why env vars are not secure for sensitive data. -
Mount a secret as a file with restricted permissions. Create a secret file:
echo "my-db-password" > /tmp/test-secret && chmod 600 /tmp/test-secret. Mount it into a container:docker run -d --name file-secret -v /tmp/test-secret:/run/secrets/db_password:ro nginx. Verify it's readable inside:docker exec file-secret cat /run/secrets/db_password. Verify it does NOT appear indocker inspect file-secret | grep my-db-password(the Env section won't contain it). Clean up withdocker rm -f file-secret && rm /tmp/test-secret. -
Decode a Kubernetes Secret. Create a K8s secret (or use kubectl locally if available):
kubectl create secret generic test-creds --from-literal=password=s3cr3t. Retrieve it:kubectl get secret test-creds -o jsonpath='{.data.password}'. Decode the base64 value:echo "<value>" | base64 -d. Confirm this shows the original plaintext. This proves K8s Secrets are encoded, not encrypted. Clean up withkubectl delete secret test-creds. -
Scan a repo for hardcoded secrets. Clone any public repo (or use a local one). Install a secret scanning tool like
trufflehog(pip install trufflehog3) orgitleaks. Run it against the repo and review the findings. If no real secrets are found, create a test file with a fake AWS key pattern (AKIA...) and confirm the scanner detects it. Delete the test file. -
Implement secret rotation with short-lived tokens. Write a shell script that generates a random password (
openssl rand -base64 32), writes it to a file, and schedules itself to run again in 60 seconds (using a loop withsleep). Start a second script that reads the file every 10 seconds and prints the current value. Observe the password changing. This simulates the dynamic secrets model where credentials rotate automatically.
Related Lessons¶
- Permission Denied — when RBAC blocks secret access
- The Container Escape — when Docker socket access exposes secrets
- The Git Disaster Recovery Guide — when secrets are committed to Git
Pages that link here¶
- Ansible From Playbook To Production
- Ansible The Complete Guide
- Aws Iam The Permissions Puzzle
- Compliance As Code Automating The Auditor
- Cross-Domain Lessons
- Github Actions Ci Cd That Lives In Your Repo
- Linux Hardening Closing The Doors
- S3 The Object Store That Runs The Internet
- Supply Chain Security Trusting Your Dependencies
- Vault Secrets That Expire On Purpose