How to store, rotate, inject, and audit credentials, API keys, and certificates so they never live in code, env files, or container images.
How to store, rotate, inject, and audit credentials, API keys, and certificates so they never live in code, env files, or container images.
Credentials hardcoded in source code, .env files committed to Git, or secrets baked into Docker images are the #1 cause of cloud breaches.
A single leaked API key can give attackers full access to your cloud account, database, or third-party services — and you may not know for months.
Secrets live in a dedicated vault with short-lived leases, automatic rotation, fine-grained access policies, and a complete audit trail of who accessed what and when.
Lesson outline
A secret is any credential or sensitive value your application uses at runtime that must not be visible to unauthorised parties.
Common secret types
If it grants access — it is a secret
Any value that, if leaked, allows an attacker to impersonate your service, access your data, or call a third-party API on your behalf must be treated as a secret. This includes "read-only" keys — they still grant access.
Most breaches involving credentials follow one of these patterns — and all of them are avoidable.
| Anti-Pattern | Why It Happens | Real Risk |
|---|---|---|
| Hardcoded in source code | Developer convenience during dev | Exposed in GitHub, code reviews, compiled binaries |
| .env files committed to Git | "It is in .gitignore" — until it is not | Leaked in CI logs, container images, public forks |
| Secrets in environment variables passed via CLI | Visible in ps aux, /proc/$pid/environ on Linux | Exploitable by any process on the same host |
| Secrets in Docker image layers | COPY .env or RUN curl -H "Authorization: $KEY" | Any docker history or image pull leaks the secret |
| Secrets shared over Slack/email | "Temporary" — then forgotten | Persists in chat history indefinitely |
| Same secret in dev/staging/prod | Convenience | Dev breach → prod breach |
| No rotation policy | "It has never been rotated because nothing broke" | A leaked secret stays valid indefinitely |
The .gitignore false sense of security
.gitignore prevents future commits of a file — but if the file was ever committed (even once, even "by accident"), it lives in Git history forever. git log --all -p -- .env will find it. Tools like truffleHog and gitleaks scan the full commit history.
HashiCorp Vault is the industry standard open-source secrets manager. Understanding its architecture clarifies how any secrets manager works.
┌─────────────────────────────────────────────────────────────┐ │ HashiCorp Vault │ │ │ │ ┌──────────────┐ ┌──────────────┐ ┌──────────────────┐ │ │ │ Auth Methods│ │ Secret Engines│ │ Audit Backends │ │ │ │ │ │ │ │ │ │ │ │ • Kubernetes │ │ • KV v2 │ │ • File │ │ │ │ • AWS IAM │ │ • AWS │ │ • Syslog │ │ │ │ • GitHub │ │ • Database │ │ • Socket │ │ │ │ • LDAP │ │ • PKI │ │ │ │ │ │ • AppRole │ │ • Transit │ │ Every request │ │ │ │ • JWT/OIDC │ │ • SSH │ │ logged with │ │ │ └──────┬───────┘ └──────┬───────┘ │ accessor+time │ │ │ │ │ └──────────────────┘ │ │ └────────┬────────┘ │ │ ▼ │ │ ┌────────────────┐ │ │ │ Policy Engine │ (HCL — who can read/write what) │ │ └────────────────┘ │ │ │ │ │ ┌────────▼───────┐ │ │ │ Storage Layer │ (Raft/Consul/etcd/DynamoDB) │ │ │ Encrypted at │ │ │ │ rest (AES-256)│ │ │ └────────────────┘ │ └─────────────────────────────────────────────────────────────┘
Vault decouples authentication (who you are) from authorisation (what you can access) from storage (encrypted at rest).
Key Vault concepts
Static secrets (a password you store and retrieve) are dangerous because they last forever unless manually rotated. Dynamic secrets are generated on-demand and expire automatically.
How dynamic database credentials work
Your app requests credentials from Vault → Vault creates a temporary DB user with a 1-hour TTL → App uses those credentials → TTL expires → Vault auto-revokes that DB user. If the credentials leak, they expire in 1 hour — not years.
Dynamic secret lifecycle (Database engine)
01
App authenticates to Vault using its Kubernetes service account JWT
02
Vault validates the service account with the Kubernetes API
03
App requests credentials: vault read database/creds/my-role
04
Vault connects to the DB and executes: CREATE USER "v-app-xyz" WITH PASSWORD "..." VALID UNTIL NOW() + INTERVAL '1 hour'
05
Vault returns username + password + lease_id + TTL to the app
06
App uses credentials for DB connections
07
At TTL expiry, Vault executes: DROP USER "v-app-xyz" — credential is gone
App authenticates to Vault using its Kubernetes service account JWT
Vault validates the service account with the Kubernetes API
App requests credentials: vault read database/creds/my-role
Vault connects to the DB and executes: CREATE USER "v-app-xyz" WITH PASSWORD "..." VALID UNTIL NOW() + INTERVAL '1 hour'
Vault returns username + password + lease_id + TTL to the app
App uses credentials for DB connections
At TTL expiry, Vault executes: DROP USER "v-app-xyz" — credential is gone
Dynamic secrets eliminate the rotation problem
Static credentials need a rotation schedule, reminders, runbooks, and someone to actually do it. Dynamic credentials rotate themselves — by design. The "rotation" is just the credential expiring and a new one being issued on next request.
1# Dynamic database credentials — request and use in one step2# Vault CLIvault read = request new dynamic credentials3vault read database/creds/myapp-role4# Key Value5# --- -----6# lease_id database/creds/myapp-role/AbCdEf1234567# lease_duration 1h8# lease_renewable true9# password A1B2-c3d4-E5f6-G7H8 ← auto-generated, expires in 1h10# username v-myapp-AbCdEf ← auto-created DB user1112# Kubernetes — Vault Agent Sidecar injection13# The sidecar requests secrets from Vault and writes them to a shared volume14# No Vault SDK needed in your application code15apiVersion: v116kind: PodAgent sidecar — no Vault SDK in your app code17metadata:18annotations:Kubernetes service account auth — no static token needed19vault.hashicorp.com/agent-inject: "true"20vault.hashicorp.com/role: "myapp-role"21vault.hashicorp.com/agent-inject-secret-db: "database/creds/myapp-role"22vault.hashicorp.com/agent-inject-template-db: |23{{- with secret "database/creds/myapp-role" -}}24DB_USER={{ .Data.username }}25DB_PASS={{ .Data.password }}26{{- end -}}27spec:28containers:29- name: myapp30image: myapp:latest31# App reads /vault/secrets/db — populated by the Vault Agent sidecar32env:33- name: DB_CREDS_FILE34value: /vault/secrets/db
Getting secrets from a vault into a running container without touching environment variables (which leak into process listings) requires one of these patterns.
| Pattern | How It Works | Pros | Cons |
|---|---|---|---|
| Vault Agent Sidecar | Sidecar container fetches secrets, writes to shared volume | No SDK changes, auto-renewal | Extra container per pod |
| External Secrets Operator (ESO) | K8s operator syncs vault secrets → K8s Secrets | Native K8s objects, GitOps-friendly | Secrets stored in etcd (encrypt at rest!) |
| Secrets Store CSI Driver | Mounts secrets as a volume from external provider | No K8s Secret object created | Complex setup, driver per provider |
| Init Container | Init container fetches secrets, writes to shared emptyDir | Simple, no sidecar | No auto-renewal, manual refresh |
| Direct SDK (Vault API) | App code calls Vault API directly at startup | Full control, dynamic renewal | SDK dependency, code changes required |
External Secrets Operator is the GitOps-friendly choice
ESO lets you define an ExternalSecret CR that says "fetch this path from Vault and create a K8s Secret named X". Your manifests are in Git (no real secrets), and ESO syncs them. Combine with sealed-secrets or encrypt etcd at rest to protect the K8s Secret objects.
Never use Kubernetes Secrets as your secrets store
Kubernetes Secrets are base64-encoded (not encrypted) by default and stored in etcd. Any user with "get secrets" RBAC permission can decode them. Always encrypt etcd at rest and use a proper vault for the source of truth.
1# External Secrets Operator — sync Vault secret to K8s Secret2apiVersion: external-secrets.io/v1beta1SecretStore — where to fetch secrets from3kind: SecretStore4metadata:5name: vault-backend6namespace: production7spec:8provider:9vault:10server: "https://vault.internal:8200"11path: "secret"12version: "v2"13auth:14kubernetes:15mountPath: "kubernetes"Kubernetes service account auth — no static Vault token16role: "myapp-production"17---18apiVersion: external-secrets.io/v1beta119kind: ExternalSecret20metadata:21name: myapp-db-secret22namespace: productionExternalSecret — what to fetch and where to put it23spec:24refreshInterval: 15m # Re-sync every 15 minutes25secretStoreRef:26name: vault-backend27kind: SecretStore28target:refreshInterval — how often to re-sync from Vault29name: myapp-db-creds # Creates this K8s Secret30creationPolicy: Owner31data:32- secretKey: DB_PASSWORD # Key in K8s Secret33remoteRef:34key: secret/data/production/myapp # Path in Vault35property: db_password # Field within that path
Rotation is the practice of replacing a secret with a new value on a schedule. Static secrets that never rotate are ticking time bombs.
Automated rotation workflow (AWS Secrets Manager example)
01
Rotation trigger fires (schedule: every 30 days, or immediately on suspected leak)
02
Secrets Manager invokes a Lambda rotation function
03
Lambda creates new DB password and sets it on the RDS instance
04
Lambda updates the secret value in Secrets Manager with the new password
05
Lambda tests the new credentials (optional but recommended)
06
Old version is demoted to AWSPREVIOUS (kept for rollback)
07
Applications using Secrets Manager SDK automatically get the new value on next fetch
Rotation trigger fires (schedule: every 30 days, or immediately on suspected leak)
Secrets Manager invokes a Lambda rotation function
Lambda creates new DB password and sets it on the RDS instance
Lambda updates the secret value in Secrets Manager with the new password
Lambda tests the new credentials (optional but recommended)
Old version is demoted to AWSPREVIOUS (kept for rollback)
Applications using Secrets Manager SDK automatically get the new value on next fetch
Zero-downtime rotation requires dual-active credentials
During rotation, both old and new credentials are temporarily valid. The DB accepts both while applications cache refresh. Once all apps have the new secret, the old one is revoked. This "dual-write" period prevents connection failures during rotation.
| Secret Type | Rotation Strategy | Typical TTL |
|---|---|---|
| Database passwords | Vault Database engine / AWS Secrets Manager rotation Lambda | 1–24 hours (dynamic) or 30–90 days (static) |
| API keys (third-party) | Manual rotation + automated alerts for age > 90 days | 90 days |
| TLS certificates | cert-manager (Let's Encrypt), AWS ACM auto-renewal | 90 days (Let's Encrypt) |
| Cloud IAM access keys | Avoid long-lived keys; use IAM roles / instance profiles instead | Never (use roles) |
| OAuth client secrets | Rotation via IdP API + application config update | 1 year |
| SSH host keys | Vault SSH engine (OTP or signed certificates) | Per-session (OTP) |
CI/CD pipelines need secrets to deploy infrastructure, push images, and call APIs — but pipeline logs are often public and artifacts are archived.
Safe patterns for CI/CD secrets
OIDC eliminates static CI secrets entirely
With GitHub Actions OIDC + AWS: the workflow requests a JWT from GitHub → exchanges it for temporary AWS credentials via sts:AssumeRoleWithWebIdentity → credentials expire when the job finishes. Zero secrets stored in GitHub Settings.
1# GitHub Actions — OIDC federation with AWS (no static AWS keys)2name: Deploy34on:5push:6branches: [main]78permissions:id-token: write is required to request OIDC token9id-token: write # Required for OIDC token request10contents: read1112jobs:13deploy:14runs-on: ubuntu-latest15steps:16- uses: actions/checkout@v41718- name: Configure AWS credentials via OIDC19uses: aws-actions/configure-aws-credentials@v4OIDC — no static AWS keys stored in GitHub Secrets20with:21role-to-assume: arn:aws:iam::123456789012:role/github-actions-deploy22aws-region: us-east-123# No AWS_ACCESS_KEY_ID or AWS_SECRET_ACCESS_KEY needed!24# GitHub generates a JWT, AWS exchanges it for temp credentials2526- name: Fetch app secrets from Vault27uses: hashicorp/vault-action@v228with:Vault also accepts OIDC JWT — single auth mechanism29url: https://vault.internal:820030method: jwt # Use the GitHub OIDC JWT to auth to Vault too31role: github-deploy32secrets: |33secret/data/production/myapp db_password | DB_PASSWORD ;34secret/data/production/myapp api_key | STRIPE_KEY3536- name: Deploy37run: |38# DB_PASSWORD and STRIPE_KEY are available as env vars39# They are masked in logs automatically40./deploy.sh
Secrets management appears in system design rounds (security layer of any cloud architecture), DevSecOps rounds (supply chain and pipeline security), and incident post-mortems (root cause: leaked credential).
Common questions:
Strong answer: Mentions OIDC federation for CI, dynamic secrets with TTLs, audit logging requirements, and pre-commit hooks for secret scanning.
Red flags: Suggesting .env files in production, not knowing what secret rotation means, or thinking base64 = encryption.
Quick check · Secrets Management
1 / 4
Key takeaways
A developer accidentally pushes an AWS access key to a public GitHub repo. What are the first three things you do?
(1) Immediately revoke/delete the AWS access key in IAM — do not wait, assume it has already been found. (2) Check CloudTrail for API calls made with that key in the last 24–72 hours to assess blast radius. (3) Create a new key (or better, replace with an IAM role) and update all legitimate consumers. Then run truffleHog on the full repo history to check for other leaked secrets.
What is the difference between Vault's KV engine and its Database engine?
KV (Key-Value) stores static secrets you write manually — Vault stores them and you retrieve them. The Database engine is dynamic: Vault connects to your DB and generates a unique, time-limited username/password on each request, then automatically revokes it when the TTL expires. The Database engine eliminates long-lived DB credentials entirely.
Why is "encrypt the secret before storing in Git" not a good secrets management strategy?
The encryption key itself becomes the secret you need to manage — you have just moved the problem. You also lose audit logging (who decrypted it when?), automatic rotation, fine-grained access control, and lease-based revocation. It is acceptable for bootstrapping (sealed-secrets in K8s, SOPS for config files) but not a replacement for a proper secrets manager.
From the books
Hacking the Cloud (practical blog series, hacking.cloud)
Documents real-world AWS credential theft techniques used by attackers — understanding the attacker's playbook is essential for building effective defences.
HashiCorp Vault: Up & Running (O'Reilly)
Chapters 4–6: Secret Engines and Dynamic Secrets
Covers Vault architecture, auth methods, secret engines, and operational patterns. The most comprehensive resource for Vault implementation.
💡 Analogy
A vault for your house keys
⚡ Core Idea
You would not leave your house key under the doormat (hardcoded in code), share it with everyone (broad IAM policies), or never change the lock (no rotation). A secrets manager is a guarded lockbox: you prove who you are, you get the key you need for the time you need it, and every access is logged.
🎯 Why It Matters
Credentials are the skeleton keys to your entire infrastructure. One leaked API key can mean full database access, data exfiltration, ransomware deployment, or fraudulent API charges — without any exploit of application logic.
Related concepts
Explore topics that connect to this one.
Ready to see how this works in the cloud?
Switch to Career Paths for structured paths (e.g. Developer, DevOps) and provider-specific lessons.
View role-based pathsSign in to track your progress and mark lessons complete.
Questions? Discuss in the community or start a thread below.
Join DiscordSign in to start or join a thread.