L48 · Secrets management
Module 7: Cloud, Containers, and Kubernetes · ~20 min · Lesson 48 of 71
🎯 What you'll learn
- Why a Kubernetes Secret is only base64, and what EKS etcd encryption with KMS does and doesn't fix
- Two ways to get a secret from Secrets Manager or Vault into a pod: External Secrets Operator and the Secrets Store CSI driver
- When to pick Vault or AWS Secrets Manager, and how Airflow should find its connections
- How a rotated value reaches a running pod, and why a secret in git history must be rotated, not just deleted
🔥 Why this matters
A pull request adds values-dev.yaml with mongoPassword: "...". A reviewer spots it, and the
next commit blanks the line. The file looks clean, so everyone moves on. Months later a pen tester
runs one git log command and logs in to MongoDB with it. L19 covered what a good secret store is
(KMS, Secrets Manager, rotation labels). This lesson is the plumbing: getting secrets from that
store into pods and DAGs without leaving copies everywhere.
1. One source of truth, a few delivery paths
Every secret should have exactly one source of truth, the store where it's created and rotated, and as few copies as possible. In a stack like the one in these examples, the paths look like this:
SOURCE OF TRUTH DELIVERY CONSUMER
Secrets Manager External Secrets Operator
or Vault ──IRSA────▶ copies it into a ─────────▶ k8s Secret ─▶ pod
│ k8s Secret (base64, etcd)
│
├──pod's IRSA─────▶ Secrets Store CSI driver ─▶ file in the pod
│ mounts it at pod start (no k8s Secret)
│
└──MWAA role──────▶ Airflow secrets backend ──▶ hook in a DAG
looks it up at run time
Each arrow authenticates with a workload identity (an IAM role, or for Vault the pod's service account), not a stored password. Each box on the right is a copy you now have to protect.
2. Kubernetes Secrets: base64, etcd and RBAC
A Kubernetes Secret stores values as base64, an encoding with no key: anyone who can read
the object can decode it in one command (Try it below). L19's second deep dive drew who can read
one: RBAC get/list on secrets, anyone who can create a pod in the namespace, node access, and
anyone with etcd or its backups.
Encryption fixes only the last of those. Per AWS's docs, EKS 1.28+ already envelope-encrypts all Kubernetes API data in etcd with an AWS owned KMS key. You can add your own customer managed key, so decryption shows up in your CloudTrail and your key policy decides who can use it:
aws eks describe-cluster --name payments-dev \
--query 'cluster.encryptionConfig[0].provider.keyArn'
# "arn:aws:kms:us-east-1:111122223333:key/1a2b3c4d-EXAMPLE"
# null = no customer managed key (AWS owned key still used)
It's enabled with aws eks associate-encryption-config, and per AWS's docs it can't be turned off
afterwards. If that KMS key is disabled or deleted, the cluster can't read its Secrets. None of
this stops a user or pod that RBAC lets in: the API server decrypts for them.
3. Getting secrets into pods: ESO or the CSI driver
External Secrets Operator (ESO) is a controller. You create an ExternalSecret that says
"copy prod/recon/mongo from Secrets Manager into a Secret called recon-mongo, every 15
minutes". Pods use it like any Secret. Simple, but the value now also lives in etcd as base64.
The Secrets Store CSI driver (with the AWS provider) mounts the secret as a file when the pod starts, using the pod's own IRSA or Pod Identity role. No Kubernetes Secret is created unless you turn on syncing. The catches: the pod can't start if the store is unreachable, and you get files; env vars need that optional sync.
Rule of thumb: CSI when per-pod identity and "never in etcd" matter (card or customer data); ESO when you need a normal Secret, for example for an ingress TLS cert or a third-party Helm chart.
4. Vault or AWS Secrets Manager?
| AWS Secrets Manager | HashiCorp Vault (or OpenBao) | |
|---|---|---|
| Who runs it | AWS | You, or HashiCorp's paid cloud service |
| How callers authenticate | IAM: roles, IRSA, Pod Identity | Many methods: Kubernetes, AWS IAM, OIDC, AppRole |
| Dynamic secrets | No; it stores and rotates (Lambda, managed rotation for some AWS databases) | Yes: per-caller DB users, AWS keys, certificates that expire (first deep dive) |
| Audit trail | CloudTrail | Vault audit devices, which you must ship and keep |
| Running cost | Per secret and per API call | Infrastructure, upgrades, unsealing, on-call |
On an AWS-only stack, Secrets Manager is the sensible default: nothing to run, and access is plain IAM. Vault earns its operating cost with multiple clouds or data centers, dynamic database credentials, or an internal PKI. One licensing note: Vault moved to the Business Source License in 2023; OpenBao is the open-source fork under the Linux Foundation.
5. Airflow: connections, variables and secrets backends
Airflow looks up a connection or variable in a fixed order, and stops at the first hit:
MongoHook(mongo_conn_id="recon_mongo")
│ 1. secrets backend: airflow/connections/recon_mongo
│ (Secrets Manager, via the MWAA execution role)
│ 2. env var: AIRFLOW_CONN_RECON_MONGO
│ 3. metadata DB: connections created in the UI
▼
first match wins
On MWAA you set secrets.backend to
airflow.providers.amazon.aws.secrets.secrets_manager.SecretsManagerBackend and
secrets.backend_kwargs to prefixes such as {"connections_prefix": "airflow/connections", "variables_prefix": "airflow/variables"} (L28 showed the DAG side). Three habits:
- Scope the execution role to
secret:airflow/*, not every secret in the account. - No
Variable.get()at the top of a DAG file. That code runs on every parse, roughly every 30 seconds, and each run is a Secrets Manager call. Read variables inside tasks or templates. - Don't count on masking. Airflow hides connection passwords and variables named like
password,secretorapi_keyin task logs, but not a value you've base64-encoded or split.
6. Rotation and git history
Rotation (L19) changes the value in the store. Whether your pod notices depends on the path:
t=0 Secrets Manager rotates: AWSCURRENT = v2
≤15m ESO refreshInterval: k8s Secret now holds v2
+~1m kubelet updates mounted Secret files (not subPath mounts)
env vars: still v1 until the pod restarts
app re-reads the file on an auth error (L19) and gets v2
later v1 retired: anything still holding it fails
The CSI driver re-reads only if rotation is enabled (enableSecretRotation, polling every 2
minutes by default). Env vars never change; that's one more reason to prefer files.
Git history is where deleted secrets live forever. Removing a line adds a commit; the old one
still holds the value, and so does every clone, fork, CI cache and pull request ref. Rewriting
history with git filter-repo cleans your copy only. GitHub's own guidance: treat the secret as
compromised and revoke it first. Scan with gitleaks or trufflehog (L28's second deep dive),
and turn on push protection so it doesn't happen again.
delete the line ──▶ fixes the latest commit only
rewrite history ──▶ fixes your remote, not clones/forks
rotate the secret ──▶ fixes every copy, everywhere
🧪 Worked example: recon-loader's password, out of Helm values
Before: the password sits in values-prod.yaml and the chart renders it into a Secret.
mongo:
user: recon_loader
password: "Ex4mple-NOT-real" # in git, in every clone
After: store it once (prod/recon/mongo in Secrets Manager), and let ESO copy it. The
SecretStore authenticates with an IRSA service account in the recon namespace:
apiVersion: external-secrets.io/v1 # v1beta1 on older ESO
kind: ExternalSecret
metadata:
name: recon-mongo
namespace: recon
spec:
refreshInterval: 15m
secretStoreRef: {name: aws-sm, kind: SecretStore}
target: {name: recon-mongo} # the Secret ESO writes
data:
- secretKey: password
remoteRef: {key: prod/recon/mongo, property: password}
The IRSA role may read that one secret and nothing else. Secrets Manager adds six random
characters to every secret ARN, hence the -* (one string, wrapped to fit):
{
"Effect": "Allow",
"Action": ["secretsmanager:GetSecretValue",
"secretsmanager:DescribeSecret"],
"Resource": "arn:aws:secretsmanager:us-east-1:111122223333:
secret:prod/recon/mongo-*"
}
The Deployment mounts recon-mongo as a volume at /var/run/secrets/recon instead of an env var,
so a rotation arrives without a restart. Then rotate the old password, because it's still in git.
🛠 Try it (optional, ~5 min)
All local, all fake. First, decode a Secret's values the way an attacker would:
echo 'cmVjb25fbG9hZGVy' | base64 -d; echo
# recon_loader
echo 'RXg0bXBsZS1OT1QtcmVhbA==' | base64 -d; echo
# Ex4mple-NOT-real
Now the classic outage bug. Compare:
echo 'Ex4mple-NOT-real' | base64 # RXg0...cmVhbAo=
printf '%s' 'Ex4mple-NOT-real' | base64 # RXg0...cmVhbA==
echo adds a newline, so the first Secret's password ends in \n and MongoDB says "Authentication
failed". (Older macOS base64 used -D to decode.)
Finally, watch a "deleted" secret survive, in a throwaway repo:
cd "$(mktemp -d)" && git init -q
echo 'mongoPassword: "Ex4mple-NOT-real"' > values-dev.yaml
git add . && git commit -qm "add dev values"
echo 'mongoPassword: ""' > values-dev.yaml
git commit -qam "remove password"
grep -c Ex4mple values-dev.yaml # 0: looks clean
git log -p -S Ex4mple --oneline # both commits, value shown
-S finds commits that add or remove a string, which is why rotation, not deletion, is the fix.
🧠 Key takeaways
- base64 is not protection; RBAC on
secretsand pod creation is. - EKS etcd encryption with KMS protects etcd, not what the API server hands out.
- ESO copies secrets into Kubernetes; the CSI driver mounts them as files with the pod's own role.
- Secrets Manager is the default on AWS; Vault pays off with dynamic secrets or many platforms.
- Mount secrets as files and re-read on auth failure; env vars never see a rotation.
- A secret that reached git is compromised: rotate first, rewrite history second.
📖 Words to know
| Term | Meaning |
|---|---|
| Source of truth | The one store where a secret is created and rotated; everything else is a copy |
| External Secrets Operator (ESO) | A controller that syncs secrets from an external store into Kubernetes Secrets |
| Secrets Store CSI driver | A driver that mounts secrets from an external store into a pod as files |
refreshInterval |
How often ESO re-reads the external store and updates the Secret |
| Secrets backend (Airflow) | A pluggable lookup that fetches connections and variables from an external store first |
git filter-repo |
A tool that rewrites git history to remove files or strings |
| OpenBao | The open-source fork of Vault, maintained under the Linux Foundation |
⏭ Next time
L49: container security: what's inside an image's layers, why running as root matters, distroless base images, and how scanning and signing images fit into deploys to a cluster such as EKS.
Dynamic secrets: Vault database credentials that expire on their own
A password that exists for one hour
Every secret so far was static: created once, stored, and copied to whoever needs it, until
someone rotates it. A dynamic secret is created on demand for one caller, with an expiry. When
recon-loader starts, Vault creates a brand-new MongoDB user just for that pod, valid for an hour.
Nobody else ever sees it, and Vault deletes the user when the time runs out.
recon-loader pod Vault MongoDB
│ 1. login (k8s auth) │ │
│──────────────────────────▶│ │
│ 2. read database/creds/ │ 3. createUser │
│ recon-read │ v-k8s-recon-read-x7Qk │
│──────────────────────────▶│──────────────────────────▶│
│◀── 4. user + password ────│ role: read on "recon" │
│ lease: 1h, renewable │ │
│ │ 5. lease expires: │
│ │ dropUser ─────────────▶│
Leaked credentials now have a short shelf life, every pod has its own DB user (so MongoDB's audit log says which pod did something), and there's no shared password to rotate.
Worked example: MongoDB with the database secrets engine
An admin configures it once (made-up names; the admin password is replaced right away):
vault secrets enable database
URL='mongodb://{{username}}:{{password}}@mongo.example.com'
vault write database/config/recon-mongo \
plugin_name=mongodb-database-plugin \
allowed_roles=recon-read \
connection_url="$URL:27017/admin?tls=true" \
username=vault-admin password=EXAMPLE-ONLY
vault write -f database/rotate-root/recon-mongo
rotate-root changes vault-admin's password to one only Vault knows, so the bootstrap password
in your shell history stops working. Then a role says what each generated user may do:
vault write database/roles/recon-read \
db_name=recon-mongo \
creation_statements='{"db":"admin",
"roles":[{"role":"read","db":"recon"}]}' \
default_ttl=1h max_ttl=24h
A caller allowed to use the role asks for credentials:
$ vault read database/creds/recon-read
Key Value
--- -----
lease_id database/creds/recon-read/2f6a614c-EXAMPLE
lease_duration 1h
lease_renewable true
password A1a-EXAMPLE-not-a-real-password
username v-k8s-recon-read-x7Qk2EXAMPLE
The pod renews the lease while it runs (up to max_ttl) and stops when it's done. The exact
username format depends on the plugin's username template. MongoDB Atlas uses a separate plugin,
mongodbatlas-database-plugin, which manages users through Atlas's API.
When something leaks
Revoke by prefix, and every user issued from that role is dropped at once:
vault lease revoke -prefix database/creds/recon-read
Compare that with a static password: find every consumer, rotate, redeploy, hope nothing was missed. The trade-off is availability: if Vault is down, new pods can't get database credentials, so Vault itself needs high availability, backups and monitoring.
The AWS-native alternative: no password at all
If you run MongoDB on Atlas, there's another option: AWS IAM authentication
(authMechanism=MONGODB-AWS). The pod signs a request with its IRSA role credentials and Atlas
maps that role to a database user. There's no database password to store, rotate or leak. Per
MongoDB's driver docs, this mechanism is for Atlas clusters. On AWS-only stacks, it's often a
simpler path than running Vault.
🧠 Extra takeaways
- Dynamic secrets are made per caller, expire on their own and need no shared rotation.
rotate-rootmakes the bootstrap admin password useless; do it right after setup.- Revoking a lease prefix kills every credential from a role in one command.
- For MongoDB Atlas on AWS, IAM authentication removes the password altogether.
Multi-tenant secret delivery: how one ClusterSecretStore can leak every team's secrets
The convenient setup
A platform team installs ESO once for a shared EKS cluster. To keep it simple, they create one
ClusterSecretStore, a store any namespace can reference, backed by an IAM role allowed to read
secretsmanager:GetSecretValue on *. Every team just writes ExternalSecrets. It works on day
one.
Now look at it from the marketing-batch namespace:
dev in marketing-batch (may create ExternalSecrets)
│ 1. ExternalSecret: key = prod/payments/ledger-db,
│ store = ClusterSecretStore "aws-all"
▼
ESO controller (role: GetSecretValue on *)
│ 2. "ESO is allowed, so I fetch it"
▼
Secret "ledger-db" in marketing-batch
3. dev reads it with kubectl: payments' DB password
ESO is a confused deputy (L46's first deep dive): a trusted service using its broad access on
behalf of whoever asks. Being allowed to create an ExternalSecret has become being allowed to
read every secret the controller's role can reach.
Fix 1: one store per namespace, with that namespace's identity
Use a namespaced SecretStore that authenticates with a service account in that namespace, whose IRSA role reads only that team's prefix:
apiVersion: external-secrets.io/v1
kind: SecretStore
metadata:
name: aws-sm
namespace: payments
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt:
serviceAccountRef:
name: payments-secrets # IRSA: payments/* only
Now a marketing-batch ExternalSecret can only use marketing's store, and AWS refuses anything
outside marketing's prefix, whatever ESO is asked to do.
Fix 2: if you keep a ClusterSecretStore, fence it
A ClusterSecretStore can list which namespaces may use it in spec.conditions, by name or label
selector (newer releases also accept regexes). Pair that with an IAM policy that matches on
secret names or tags, for example secretsmanager:ResourceTag/team equal to the caller's team, so
the role itself can't reach other teams' secrets. Check your ESO version's docs for the exact
fields.
Fix 3: control who can write ExternalSecrets
RBAC create on externalsecrets is now a secrets permission. Treat it like get secrets:
grant it to the deploy pipeline's service account, not to every developer. Also watch
PushSecret, an ESO resource that writes to the external store; most clusters don't need it,
and it's safest to deny it by default.
Worked example: auditing a shared cluster (read-only, a cluster you're allowed to inspect)
kubectl get clustersecretstores \
-o custom-columns=NAME:.metadata.name,COND:.spec.conditions
# NAME COND
# aws-all <none> <- any namespace can use it
kubectl get externalsecrets -A -o json | jq -r '.items[]
| "\(.metadata.namespace) \(.spec.data[]?.remoteRef.key)"'
# payments prod/payments/ledger-db
# marketing-batch prod/payments/ledger-db <- wrong team
Any key outside the namespace's own prefix deserves a question, and a ClusterSecretStore with no conditions and a
* role is the finding to fix first.
How the CSI driver compares
The CSI driver authenticates as the pod's own service account, so each workload's IRSA role already limits what it can mount. Its risk is the same as for any pod: whoever can create pods with a service account can use that account's role. Either way the rule is the same: the identity that fetches a secret should belong to the team that owns it.
🧠 Extra takeaways
- A ClusterSecretStore with a broad role turns "create ExternalSecret" into "read any secret".
- Prefer namespaced SecretStores, each using its own namespace's IRSA role and prefix.
- Fence shared stores with
conditionsand tag- or name-scoped IAM policies. - Treat RBAC on
externalsecretsandpushsecretsas secrets permissions.
The quiz for this lesson and the Tests section need JavaScript.