click the screen · press Enter
← back to blog
Kubernetes Journey · Topic 31

Kubernetes Secrets Are Not Secret

A Kubernetes Secret shown as base64 text being decoded back to a plain password, with RBAC and encryption at rest as the controls that actually protect it - Kubernetes Journey

I promised this one two topics ago and then got distracted by policy engines. Here it is, and it starts with the least surprising demo in Kubernetes: I created a Secret, ran two commands, and had the password back on screen in about a second.

That's not a bug and it isn't news to anyone who has read the docs. It's just that the word "Secret" does a lot of work it hasn't earned, and I've watched people (me included, early on) treat one like a vault. So let's be precise about what a Secret actually buys you, what it definitely doesn't, and the four gaps worth closing.

Kubernetes Secrets compared: base64 encoding decoded in one command and stored as plain text in etcd, versus RBAC, encryption at rest, file mounts and bound tokens as the controls that actually protect a Secret
The left column is what people think a Secret gives them. The right column is what actually protects one.

What a Secret really is

A Secret is an object in the Kubernetes API that holds small key-value pairs, base64 encoded, stored in etcd, and handed to a pod by the kubelet on the node where that pod runs.

You create it, you reference it from a pod, and Kubernetes delivers the values into the container as environment variables or as files. The application reads them at runtime. It never sees them at build time, so the credential is not baked into the image.

Here's the real win, and it's worth saying plainly. The credential leaves your source code, leaves your container image, and leaves your git history. Instead it becomes a separate object with its own name, its own namespace, and its own line in RBAC. Someone allowed to deploy your app is not automatically allowed to read your database password. That separation is the point.

A Secret's job is to move the credential out of the code and into something you can put access controls on. Confidentiality is a thing you add on top, not a thing you get for free.

The comparison people ask about A ConfigMap and a Secret are almost the same object. Both hold key-value data, both mount the same way. The Secret is base64 encoded, can be encrypted at rest, and is a separate resource in RBAC rules. That last one is the reason to use it. Put the database hostname in a ConfigMap and the database password in a Secret, and the difference isn't secrecy, it's that you can grant read on one and not the other.

base64 is a wrapper, not a lock

Here's the demo. A tiny Secret for an imaginary payments service:

kubectl create secret generic payments-db \
  --from-literal=DB_HOST=postgres.data.svc \
  --from-literal=DB_USER=payments_ro \
  --from-literal=DB_PASSWORD='Tr1cky-N0t-S3cret'

Now look at it two ways. describe is coy about the values, which is where a lot of the false comfort comes from:

kubectl describe secret payments-db
Name: payments-db Type: Opaque Data ==== DB_HOST: 17 bytes DB_USER: 11 bytes DB_PASSWORD: 17 bytes
kubectl get secret payments-db -o yaml
data: DB_HOST: cG9zdGdyZXMuZGF0YS5zdmM= DB_USER: cGF5bWVudHNfcm8= DB_PASSWORD: VHIxY2t5LU4wdC1TM2NyZXQ= type: Opaque
Same object, two commands. describe tells you a value is 17 bytes long. -o yaml hands you the value.

And getting from that string to the password is one pipe:

kubectl get secret payments-db -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
# Tr1cky-N0t-S3cret

base64 exists so binary data can travel through things that only handle text. It's the same encoding that carries attachments through email. There is no key, no password, nothing to crack. Calling it obfuscation is generous.

Try itDecode a Secret yourself, in this tab
the Secret above the newline bug
Runs entirely in your browser, nothing is sent anywhere. No cluster, no kubectl, no root. That's the argument in one widget.

Three ways to make one, and the two that bite

The quick way is straight from the command line, which is what I used above. It's fine for a lab and it has one obvious problem: that password is now sitting in your shell history in plain text.

# reads the value from a file, so nothing lands in your shell history
kubectl create secret generic payments-db --from-file=./db_password

# name the key yourself instead of using the filename
kubectl create secret generic ssh-creds \
  --from-file=ssh-privatekey=./id_ed25519 \
  --from-file=ssh-publickey=./id_ed25519.pub

# a whole directory: every file in it becomes a key
kubectl create secret generic bundle --from-file=./creds-dir/

# an env file (KEY=value lines), handy for porting a .env across
kubectl create secret generic payments-db --from-env-file=./payments.env

# mix and match
kubectl create secret generic ssh-creds \
  --from-file=ssh-privatekey=./id_ed25519 \
  --from-literal=passphrase='Tr1cky-N0t-S3cret'

# generate the YAML without creating anything, so you can review it first
kubectl create secret generic payments-db \
  --from-literal=DB_USER=payments_ro \
  --dry-run=client -o yaml

The --from-file variants are worth knowing beyond the tidiness. Anything you pass with --from-literal is in your history, in your terminal scrollback, and quite possibly in a shell session recording somewhere. The file forms keep the value off the command line entirely.

For anything you keep, write the YAML. And here's the bit a lot of older tutorials get wrong: you do not have to base64 encode by hand. The stringData field takes plain text and Kubernetes does the encoding when it stores the object:

apiVersion: v1
kind: Secret
metadata:
  name: payments-db
type: Opaque
stringData:
  DB_HOST: postgres.data.svc
  DB_USER: payments_ro
  DB_PASSWORD: Tr1cky-N0t-S3cret
apiVersion: v1
kind: Secret
metadata:
  name: payments-db
type: Opaque
data:
  DB_HOST: cG9zdGdyZXMuZGF0YS5zdmM=
  DB_USER: cGF5bWVudHNfcm8=
  DB_PASSWORD: VHIxY2t5LU4wdC1TM2NyZXQ=
Both produce an identical object. stringData is write-only: read the Secret back and you get the encoded data either way.

Neither of those files is safe to commit. Encoding the password changes nothing about that, which is exactly why I'd rather write stringData: it looks as dangerous as it is. A wall of base64 in a repo has fooled more than one reviewer into scrolling past.

The one-byte bug that eats an evening If you do encode by hand, use echo -n. Without -n, echo adds a trailing newline and encodes it too, so your application authenticates with a password one byte longer than the real one. The error you get back is a plain authentication failure, with nothing anywhere hinting that the value is almost right. Try the second preset in the widget above and you'll see the newline sitting there in the decoded output.

Environment variable or mounted file? Not a coin flip

Every tutorial presents these as two equal options. They aren't, and the difference is a security one.

# as environment variables: every key becomes an env var
envFrom:
  - secretRef:
      name: payments-db

# as files: every key becomes a file in the mounted directory
volumes:
  - name: db-creds
    secret:
      secretName: payments-db
      defaultMode: 0400
containers:
  - name: api
    volumeMounts:
      - name: db-creds
        mountPath: /etc/payments/creds
        readOnly: true
Environment variables vs mounted files
BehaviourEnv varsMounted files
Child processesInherit the whole environmentInherit nothing, must open the file
Visible in /proc/PID/environYes, to anything in the containerNo
Crash dumps and error trackersEnvironments get swept up routinelyNot unless you log the file
Updates when the Secret changesNever, needs a pod restartYes, kubelet refreshes it
Per-file permissionsNo such conceptdefaultMode, e.g. 0400
Four of those five rows favour files. The rotation row is the one people feel first: an env var is a snapshot taken at pod start.

I default to volume mounts now, and the reason isn't really the theory. It's that "the environment gets collected" is exactly how credentials end up in a third-party error tracker, sitting in a SaaS dashboard nobody thinks of as part of the cluster. Sentry, a stack-trace logger, a debug endpoint that dumps os.environ when someone is troubleshooting at 2am. None of those read files off disk. All of them read the environment.

Check what actually landed:

kubectl exec -it payments-api -- sh
$ ls -l /etc/payments/creds -r-------- 1 root root 17 Aug 5 12:14 DB_PASSWORD -r-------- 1 root root 17 Aug 5 12:14 DB_HOST $ cat /etc/payments/creds/DB_PASSWORD Tr1cky-N0t-S3cret
Inside the container it's plain text either way. The controls are about who gets inside the container, and who could have read it before it got there.

Proof: reading a Secret straight out of etcd

Every object in Kubernetes ends up in etcd, the cluster's database. By default a Secret lands there as plain text. Not base64, plain text, because the API server decodes it on the way in. Talking about that is one thing, so let's go and look.

Everything from here runs on a control plane node of a single-node lab cluster. Don't do it to anything you don't own.

Step 1: get etcdctl. It usually isn't installed:

etcdctl
-bash: etcdctl: command not found $ apt-get install -y etcd-client Setting up etcd-client ... $ etcdctl version
On Debian and Ubuntu the package is etcd-client. Some distros ship it as etcd.
Set ETCDCTL_API=3 anyway Older packages default to the v2 API and print a warning telling you so, and the v2 client cannot see anything Kubernetes writes, because Kubernetes uses v3. From etcd 3.4 onwards v3 is the default and the variable is redundant. Setting it costs nothing and saves you a confusing empty result on an old box.

Step 2: read the key. Secrets live under /registry/secrets/<namespace>/<name>. etcd speaks mutual TLS, so you need the certificates kubeadm already put on the node:

ETCDCTL_API=3 etcdctl \
  --cacert /etc/kubernetes/pki/etcd/ca.crt \
  --cert   /etc/kubernetes/pki/etcd/server.crt \
  --key    /etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/payments-db | hexdump -C | head -20
etcdctl get /registry/secrets/default/payments-db | hexdump -C
2f 72 65 67 69 73 74 72 |/registr| 79 2f 73 65 63 72 65 74 |y/secret| 6b 38 73 00 0a 0c 0a 02 |k8s.....| 54 72 31 63 6b 79 2d 4e |Tr1cky-N| 30 74 2d 53 33 63 72 65 |0t-S3cre| 74 |t| == the real password, in the database, in the clear ==
The right-hand column of a hexdump is the ASCII. No base64, no decoding step. It's just there.

That's the point of the whole exercise. Anyone holding an etcd snapshot, a backup tarball, or a disk image of a control plane node holds every credential in the cluster. No API server involved, no RBAC involved, nothing to bypass.

Turning encryption at rest on, start to finish

Step 3: check whether it's already on. Two ways, and I'd do both because one reads the intended config and one reads what's actually running:

grep -n 'encryption-provider-config' /etc/kubernetes/manifests/kube-apiserver.yaml

ps aux | grep [k]ube-apiserver | tr ' ' '\n' | grep encryption

No output from either means Secrets are going into etcd unencrypted, which is the default on a plain kubeadm cluster.

Step 4: generate a key. The local providers want 32 random bytes, base64 encoded:

head -c 32 /dev/urandom | base64
# 8Qn2rC0mKcYqvVv1zJ4pR7Xb9tLdWfH3eA6sN0uGiPk=

Step 5: write enc.yaml. This is where I have to flag something, because the broken version of this file gets copied around a lot:

apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
  - resources:
      - secrets
    providers:
      - secretbox:
          keys:
            - name: key1
              secret: 8Qn2rC0mKcYqvVv1zJ4pR7Xb9tLdWfH3eA6sN0uGiPk=
      - identity: {}
Provider order is the whole configuration The first provider in that list is the one used to encrypt anything written. Every provider after it is only used for decryption, so old objects keep working. Put identity: {} first, as several examples online do, and identity means "store it as-is". Your config looks like encryption, applies cleanly, reports no error, and encrypts precisely nothing. identity belongs last. It has to be there, though, or the API server can't read the Secrets that were written before you turned this on.

On the algorithm, since the choice is not obvious and the docs are blunter about it than most tutorials are:

Which provider to put first
ProviderThe catchVerdict
aescbcCBC with PKCS#7 padding, flagged for a padding oracle attackAvoid, even though most tutorials use it
aesgcmFastest, but random nonces mean the key must be rotated roughly every 200,000 writesOnly with automated rotation
secretboxXSalsa20 and Poly1305. Newer, so some review processes will not have it on their listThe sane local-key default
kms (v2)Needs an external key service, so more moving partsWhat I'd want in production
I picked secretbox above. aesgcm is the one people reach for by default, and the 200k-write rotation requirement is not something you want to discover later.

And the obvious weakness of every row except the last: the key sits in a file on the control plane, next to the data it protects. Encryption at rest with a local key defends against a stolen backup. It does not defend against someone who already has the node.

Step 6: put the file somewhere the API server can reach it. The API server is a static pod, so it only sees what's mounted into it:

mkdir -p /etc/kubernetes/enc
mv enc.yaml /etc/kubernetes/enc/
chmod 600 /etc/kubernetes/enc/enc.yaml

# back this up before you touch it, seriously
cp /etc/kubernetes/manifests/kube-apiserver.yaml ~/kube-apiserver.yaml.bak

Step 7: edit the manifest. Three separate additions to /etc/kubernetes/manifests/kube-apiserver.yaml, and missing any one of them leaves you with an API server that won't start:

spec:
  containers:
    - command:
        - kube-apiserver
        # ... existing flags ...
        - --encryption-provider-config=/etc/kubernetes/enc/enc.yaml   # 1
      volumeMounts:
        # ... existing mounts ...
        - name: enc                                                   # 2
          mountPath: /etc/kubernetes/enc
          readOnly: true
  volumes:
    # ... existing volumes ...
    - name: enc                                                       # 3
      hostPath:
        path: /etc/kubernetes/enc
        type: DirectoryOrCreate
You are about to break kubectl on purpose Saving that file makes the kubelet restart the API server, and for the next 30 seconds or so every kubectl command fails with a connection refused. That's normal. What is not normal is it never coming back, which is what a typo in that YAML gets you, with no kubectl left to tell you what went wrong. Read the container log on the node instead: crictl ps -a | grep apiserver then crictl logs <id>. This is the one step where having that backup copy matters.

Step 8: verify the flag is actually live. Not that you edited the file, that the running process has it:

ps aux | grep [k]ube-apiserver | tr ' ' '\n' | grep encryption
--encryption-provider-config=/etc/kubernetes/enc/enc.yaml $ kubectl -n kube-system get pod kube-apiserver-controlplane kube-apiserver-controlplane 1/1 Running 0 41s
A fresh AGE on that pod is how you know the kubelet actually picked the change up.

Step 9: prove it works on something new. Create a second Secret and look at it in etcd:

kubectl create secret generic payments-db-2 --from-literal=key2=topsecret

ETCDCTL_API=3 etcdctl \
  --cacert /etc/kubernetes/pki/etcd/ca.crt \
  --cert   /etc/kubernetes/pki/etcd/server.crt \
  --key    /etc/kubernetes/pki/etcd/server.key \
  get /registry/secrets/default/payments-db-2 | hexdump -C | head
get /registry/secrets/default/payments-db
k8s ... payments-db ... Opaque Tr1cky-N0t-S3cret == still readable. encryption only applies on write ==
get /registry/secrets/default/payments-db-2
/registry/secrets/default/payments-db-2 k8s:enc:secretbox:v1:key1:<binary> == nothing readable, which is the whole idea ==
Look at the first tab again. This is the step people skip, and it's why a cluster can have encryption "enabled" and still hand over every credential it had before that day.

Step 10: re-encrypt everything that already existed. A Secret is only encrypted when it's next written, so read them all and write them straight back:

kubectl get secrets --all-namespaces -o json | kubectl replace -f -

Then run the etcdctl check on the original Secret one more time. If it still comes back readable, the rewrite didn't cover it, and you'd rather find that out now than during an incident. Job's not done until the old key looks like the new one.

The gap encryption doesn't close

Encryption at rest protects the database file. It does nothing at all against someone talking to the API server, because the API server decrypts on the way out. So the actual control is least privilege, and there are two doors into a Secret, not one.

01
The front door

get or list on secrets. Obvious, and usually the only one people lock

02
The side door

create on pods. Mount the Secret into a pod you control

03
Same result

Print the file, or just env. No secrets permission needed anywhere

Anyone who can create a pod in a namespace can read every Secret in that namespace. This is by design and it surprises people every time.

Worth sitting with, that one. If you grant a CI service account create on Deployments in a namespace so it can ship the app, you have also granted it read access to every credential in that namespace. Not through a bug, through the normal, documented mechanism. So when I'm assessing a cluster, "who can create workloads here" is the same question as "who can read the secrets here".

kubectl auth can-i get secrets --namespace payments
kubectl auth can-i create pods --namespace payments

# the fuller picture, as a specific service account
kubectl auth can-i --list \
  --as=system:serviceaccount:payments:deployer -n payments

The RBAC simulator in Topic 10 is handy for reasoning about a Role before you apply it, and the two-doors idea is the thing I'd check it against.

Service account tokens: the correction worth knowing

A lot of study material still shows a Secret of type kubernetes.io/service-account-token appearing automatically whenever you create a ServiceAccount, holding ca.crt, namespace and a long JWT. That behaviour is gone. Kubernetes stopped auto-creating those in version 1.24.

What happens now: a pod gets its token through a projected volume. The kubelet asks the API server for a token that's bound to that pod, tied to a specific audience, and given an expiry. It refreshes it before it runs out. The credential is short-lived and dies with the pod.

kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token

If that command returns anything on a modern cluster, look closely. A token in a Secret is a static credential that does not expire, is not bound to any pod, and works from anywhere in the world that can reach your API server. Sometimes there's a real reason for one. Often it's a leftover from a cluster that was upgraded through 1.24 and nobody swept up. Kubernetes will eventually label unused ones as invalid on its own, but "eventually" defaults to a year.

A projected token expires by itself. A token in a Secret expires when someone remembers. Treat every one you find as a finding until you know why it's there.

What I actually check

Lab clusters and authorised engagements only, obviously. But this is the short list, roughly in the order I'd run it after landing any level of API access.

# 1. what can I read at all
kubectl auth can-i --list

# 2. everything, everywhere, decoded
kubectl get secrets -A -o json | jq -r '
  .items[] | .metadata.namespace + "/" + .metadata.name as $n |
  .data // {} | to_entries[] | "\($n)  \(.key) = \(.value|@base64d)"'

# 3. credentials people typed straight into the pod spec
kubectl get pods -A -o yaml | grep -iE 'password|passwd|token|api[_-]?key' | head -40

# 4. credentials hiding in ConfigMaps, where nothing protects them
kubectl get configmaps -A -o yaml | grep -iE 'password|secret|token' | head -40

# 5. from inside a pod, the token I was given for free
cat /var/run/secrets/kubernetes.io/serviceaccount/token

Number three is the one that keeps paying. People go to the trouble of creating a Secret for the database, then put the third-party API key in a plain env: value in the Deployment, where it sits in the manifest, in git, and in kubectl get deploy -o yaml for anyone with read access. Number four is the same instinct one step worse, because a ConfigMap doesn't even get the separate RBAC treatment.

What I'd actually do about it

  1. Fix RBAC first, both doors. Scope get and list on secrets to the service accounts that genuinely need them, and treat pod-creation rights in a namespace as equivalent to read access on that namespace's Secrets. Everything below matters less than this.
  2. Turn on encryption at rest, with the providers in the right order. Then force a rewrite so the existing objects are actually covered, and check with etcdctl rather than assuming.
  3. Mount as files, not environment variables, with defaultMode: 0400 and readOnly: true. You get rotation without a restart for free.
  4. Never commit the manifest. Use stringData so the file looks as sensitive as it is, and keep it out of the repo. If Secrets have to live in git, they need to be sealed or referenced, not encoded.
  5. Mark stable Secrets immutable: true. It blocks accidental edits and takes load off the kubelet, since it stops watching for changes. You delete and recreate to rotate, which is a fine trade for something like a TLS certificate.
  6. Move the real ones out of the cluster. HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, Google Secret Manager. Something like the External Secrets Operator syncs from those into Kubernetes so your manifests reference a name rather than a value, and you inherit proper rotation and audit logging.
Quick check on any cluster kubectl get secrets -A to see the surface, kubectl auth can-i --list to see your reach, and one etcdctl get on a control plane node to find out whether encryption at rest is on. Three commands and you know most of what matters.

The name is the problem

My honest opinion: the name is the problem. If the resource had been called CredentialRef or something equally boring, nobody would assume it was encrypted, and half the bad practice around it would never have started. It does one useful job well, keeping credentials out of images and code and behind their own RBAC rule, and the whole industry read the label and assumed a vault.

The thing that changed how I look at clusters, though, is the two-doors problem. I spent a while thinking about secret permissions as the control, and then it clicked that pod-creation rights get you there anyway. It reframes RBAC reviews entirely: I no longer read a Role and ask what it can read, I ask what it can run.

Where I'm still unsure: I haven't run External Secrets Operator or Vault's sidecar injector on anything beyond a homelab cluster, so I don't have a real feel for the operational cost. Everyone says "use an external secret manager" and I believe them. I just haven't personally carried the pager for one, and I'd rather say that than pretend.

Next topic, something with a bit more teeth: container images. What's actually inside the one you're running, how a base image drags along dozens of packages nobody chose, and how small you can honestly get.

References

FAQ

Are Kubernetes Secrets encrypted?

Not by default. A Secret's values are base64 encoded, which anyone can reverse in one command, and by default they are written to etcd as plain text. Encryption at rest is a separate thing you configure on the API server, and it is off unless you turn it on.

What is the difference between a Secret and a ConfigMap in Kubernetes?

Very little, technically. Both store key-value data and both are consumed the same way. A Secret is base64 encoded, can be encrypted at rest, is not written to the node's disk in the same way, and is a separate resource in RBAC. That last point is the one that actually matters.

Should I inject Secrets as environment variables or mounted files?

Mounted files, in most cases. Environment variables are inherited by every child process, show up in /proc/PID/environ, and get swept into crash reports and error trackers. A mounted Secret also updates when you change it, whereas an environment variable is fixed until the pod restarts.

Can someone read Kubernetes Secrets without get permission on secrets?

Yes. Anyone who can create a pod in a namespace can mount any Secret in that namespace and print it. Permission to create workloads is effectively permission to read every Secret those workloads can reach, which is why pod-creation rights deserve as much care as secret-read rights.

Why is there a service account token sitting in a Secret?

Kubernetes stopped auto-creating them in version 1.24. Modern pods get a short-lived projected token that the kubelet rotates. A token still living in a Secret is either a legacy leftover or something created on purpose, and unlike a projected token it does not expire on its own.