Secrets

How secrets differ from config, and their real limits.

Intermediate10 min · lesson 16 of 65
In plain terms
A Secret is that same settings sheet, but for passwords — and by default it’s only folded shut, not locked. The real locks (encryption and access rules) are added separately.

base64 is not encryption. That one sentence trips up more Kubernetes admins than anything else about Secrets. You drop a database password into a Secret, watch it turn into a wall of gibberish characters, and it feels protected. It isn't. Anyone allowed to read that Secret turns the gibberish back into your password with a single command.

A Secret is the object Kubernetes hands you for sensitive configuration: passwords, access tokens, TLS (Transport Layer Security) certificates, signing keys. It behaves almost exactly like a ConfigMap, the object for ordinary settings that aren't sensitive. Think of a ConfigMap as a settings sheet taped to the office fridge for anyone to read. A Secret is the same sheet for the sensitive values, folded shut. Folded, not locked. The real locks get bolted on separately, and bolting them on is your job, not something the object does for you.

How a Secret differs from a ConfigMap

Mechanically the two are twins. Both hold key-value data. Both get fed into a Pod (the smallest thing Kubernetes runs, one or more containers scheduled together as a unit) either as environment variables or as files, and your app reads them the same way. So why two object types at all? Intent and handling. Kubernetes treats anything typed as a Secret as the thing worth protecting. It keeps the values out of most command output. It can restrict a Secret so it only reaches the nodes actually running a Pod that needs it. And every security control worth having, encryption at rest, access rules, audit logs, points at Secrets specifically.

The values inside are base64-encoded. base64 is just a way of rewriting bytes in a printable alphabet so binary data survives being stored as text. It's reversible by design, and reversible by anyone. Treat it as a different font, not a lock.

Create one and read it straight back

create + list the secret
kubectl create secret generic db-cred --from-literal=DB_PASSWORD='sup3rs3cr3t!'
kubectl get secret db-cred
output
secret/db-cred created
NAME TYPE DATA AGE
db-cred Opaque 1 2s

The imperative form is the fastest way in, and the one you'll grab for under exam pressure. Use --from-literal for values you type by hand, --from-file to load a file's contents as the value. Now watch how little that base64 actually buys you.

base64 is not a lock
kubectl get secret db-cred -o jsonpath='{.data.DB_PASSWORD}' | base64 -d; echo
output
sup3rs3cr3t!

No key. No special permission. jsonpath (a way to pluck one field out of a reply) pulls the encoded value straight from the API server, the Application Programming Interface server, the cluster's single front door that every kubectl command talks to. Then base64 -d hands you the plaintext. The only thing standing between a person and that password was the right to read the Secret in the first place.

Env var or mounted file, and why it matters for rotation

An environment variable is like a value copied onto a sticky note when a worker clocks in for a shift. Change the master copy later and the sticky note still shows the old value, right up until that worker clocks out and back in. A mounted file is different. It's a live copy the worker re-reads every time they glance at it. Same Secret, very different behavior the moment you rotate a credential. Here's a Pod that reads the same Secret both ways at once.

app.yaml
apiVersion: v1
kind: Pod
metadata:
name: app
spec:
containers:
- name: app
image: busybox:1.36
command: ["sleep", "3600"]
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-cred
key: DB_PASSWORD
volumeMounts:
- name: db
mountPath: /etc/db
readOnly: true
volumes:
- name: db
secret:
secretName: db-cred
apply and verify the mount
kubectl apply -f app.yaml
kubectl exec app -- cat /etc/db/DB_PASSWORD; echo
kubectl exec app -- mount | grep /etc/db
output
pod/app created
sup3rs3cr3t!
tmpfs on /etc/db type tmpfs (ro,relatime)

Look at the mount type: tmpfs. The kubelet (the Kubernetes agent that runs on every node and actually starts your containers) never writes Secret files to the node's disk. It keeps them in a memory-backed filesystem that vanishes on reboot. That protection comes free with a volume mount, and you lose it the second you switch to env vars. Rotation splits the two apart too. Update the Secret and the volume-mounted file refreshes on its own, usually within a minute or so. The lag is the kubelet's sync period plus a bit of cache propagation, and your app still has to notice the file changed and re-read it. An environment variable never refreshes. It's copied into the process once, at container start, and frozen there. Every Pod consuming that Secret as an env var keeps serving the old value until you restart it.

How a Pod consumes a Secret
How should a Pod read a Secret?
the choice decides what rotation does
as an env var
secretKeyRef in env
Copied in once at container start. No hot reload, needs a Pod restart to change. Can leak into logs, crash dumps, and child process environments.
as a mounted file
secret volume on tmpfs
Held in memory, refreshes within the kubelet sync period. The app must re-read the file. Safer default for anything that rotates.
Same Secret, opposite behavior on update: mounted files refresh in place, env vars stay frozen until the Pod restarts.

What actually protects it

Three controls do the real work here, and not one of them is the Secret simply being labeled a Secret.

Encryption at rest comes first. By default the API server writes Secret values into etcd (the cluster's key-value database, the place every object the cluster knows about actually lives) in the clear, base64 and nothing more. That's a diary written in plain ink sitting in an unlocked drawer. Grab an etcd snapshot or a nightly backup tarball and you've read every credential in the cluster, and RBAC never even entered the picture. An EncryptionConfiguration on the API server closes that hole, ideally backed by a KMS (Key Management Service, a cloud or hardware service that holds the encryption key so the cluster never stores it in the clear). Turn it on and Secret data is ciphertext before it ever touches disk.

Then there's tight RBAC. RBAC (Role-Based Access Control) is the cluster's guest list: it spells out who is allowed to do what. The part people miss is what a read actually returns. get and list hand back the whole Secret, values and all. They arrive base64-encoded, sure, but that's one base64 -d away from plaintext, the exact command you ran a minute ago. So read access to Secrets in a namespace is read access to every credential in that namespace. Check who has it before you trust your own setup.

who can read prod secrets?
kubectl auth can-i get secrets -n prod --as=system:serviceaccount:prod:ci-runner
output
yes

That yes means the ci-runner service account (a service account is the identity a program or Pod logs in as instead of a human, and this one belongs to an automated build pipeline) can pull every credential in the prod namespace. Grant Secret read to the exact namespaces and service accounts that genuinely need it, never cluster-wide out of habit. For the highest-value material, keep the source of truth outside the cluster entirely. The Secrets Store CSI (Container Storage Interface) driver mounts values from a system like HashiCorp Vault or a cloud secret store straight into the Pod, so unless you switch on its secret syncing the credential never becomes a Secret object in etcd at all. The External Secrets Operator works differently, and the difference matters: it reads from the same kind of external store but writes an ordinary Kubernetes Secret into the namespace, which then sits in etcd like any other. What it buys you is one source of truth and hands-off rotation, not a credential that has left etcd. And when a Secret never changes, set immutable: true. Think of it as laminating the card so nobody scribbles a new value onto it by accident. It also lets the kubelet on every node running a Pod that uses it drop its watch on that Secret, which takes real load off the API server on a big cluster.

get or list on Secrets returns the raw credential, so scope it tightly
A Role that grants get or list on Secrets across the whole cluster hands the holder every credential in it, one base64 decode away from plaintext, in a single command. This is the most common over-grant in real clusters. Give Secret read access only to the specific namespaces and service accounts that truly need it. Turn on encryption at rest so an etcd snapshot or backup isn't a shortcut straight around your RBAC. And push the highest-value credentials out to an external secret manager.

stringData is for humans, data is for already-encoded bytes. Mixing both is fine, but stringData is write-only: the API server folds it into data on the way in, so a read never hands it back. And kubectl describe secret prints neither value, only key names and byte counts like DB_PASSWORD: 12 bytes. When you actually need to see a value, go back to get -o jsonpath piped through base64 -d.

Never commit Secret YAML with live values. Sealed Secrets, SOPS, or a vault CSI driver belong in the pipeline.

automountServiceAccountToken is a different secret path — the projected token. Turn it off when unused.

Try this

Create a generic Secret, get it, and base64-decode a key. Then mount it into a Pod, confirm the file arrives on a tmpfs mount, and rotate the value so you can watch the mounted file catch up while the environment variable stays stuck. Treat the demo as a reminder that etcd contents are sensitive.

1. create it, then decode it
kubectl create secret generic db-cred --from-literal=DB_PASSWORD='sup3rs3cr3t!'
kubectl get secret db-cred
kubectl get secret db-cred -o jsonpath='{.data.DB_PASSWORD}' | base64 -d; echo
output
secret/db-cred created
NAME TYPE DATA AGE
db-cred Opaque 1 2s
sup3rs3cr3t!
2. mount it and check the mount type
kubectl apply -f app.yaml
kubectl exec app -- cat /etc/db/DB_PASSWORD; echo
kubectl exec app -- mount | grep /etc/db
output
pod/app created
sup3rs3cr3t!
tmpfs on /etc/db type tmpfs (ro,relatime)
3. rotate the value, then give the kubelet a minute or two
kubectl create secret generic db-cred --from-literal=DB_PASSWORD='n3wp4ssw0rd!' --dry-run=client -o yaml | kubectl replace -f -
output
secret/db-cred replaced
4. same Pod, two different answers
kubectl exec app -- cat /etc/db/DB_PASSWORD; echo
kubectl exec app -- sh -c 'echo "$DB_PASSWORD"'
output
n3wp4ssw0rd!
sup3rs3cr3t!

The mounted file followed the rotation. The environment variable is still the copy taken when the container started, and it stays wrong until the Pod restarts.

5. check who else can read it
kubectl auth can-i get secrets -n prod --as=system:serviceaccount:prod:ci-runner
output
yes

Takeaway

Secrets are base64 in the API by default, not encryption. RBAC, encryption at rest, and external stores are what make them real.

Quick check
01You update a Secret with a new database password and re-apply it. Pods that read it as an environment variable keep failing auth with the old password. Why?
Incorrect — base64 is lossless and reversible; it never corrupts a value.
Incorrect — immutable only blocks edits; the default already allows updates.
Correct — Restart the Pods, or mount the Secret as a volume, which refreshes in place.
Incorrect — encryption at rest is transparent to reads and writes; it never blocks an update.
02The lesson stresses that base64 gives essentially no protection. If someone steals an etcd snapshot or a backup tarball, which control actually keeps the Secret values from being read?
Incorrect — base64 is reversible by anyone regardless of alphabet; it's a different font, not a lock.
Incorrect — RBAC governs API access, but someone holding a raw etcd snapshot has bypassed the API server entirely, so RBAC never enters the picture.
Incorrect — tmpfs protects the file as mounted into a running Pod, not the copy the API server writes into etcd.
Correct — without it the API server writes Secrets to etcd in the clear (base64 only); encryption at rest, ideally with a Key Management Service holding the key, is what makes a stolen snapshot useless.
03kubectl auth can-i get secrets -n prod --as=system:serviceaccount:prod:ci-runner returns yes. In terms of exposure, what does that grant actually mean?
Correct — a get or list hands back the full Secret data, so read access to Secrets in a namespace is read access to every credential in it; scope it to only the accounts that truly need it.
Incorrect — get returns the values, not just metadata; the base64 in the reply decodes to plaintext with one command.
Incorrect — the verb applies to all Secrets in the namespace, not just ones the caller owns.
Incorrect — there is no separate decrypt step; encryption at rest is transparent to an authorized API read.

Related