Control plane & etcd
Encrypt secrets at rest, lock the API server, guard the CA.
Every request to the API server, the cluster's single front door, gets frisked. Who are you (that's authentication), and are you allowed to do this (that's authorization, which Kubernetes runs through Role-Based Access Control, or RBAC). The database sitting behind that front door frisks no one. That database is etcd, the cluster's key-value store, and it will hand its entire contents to anyone who can open a connection to it. Think of etcd as the building's master key cabinet. The front desk checks badges and logs every visitor who walks in. The cabinet in the back just holds every key to every room, and if someone leaves it unlocked, all the rules at the front desk quietly stop meaning anything.
Everything Kubernetes knows is a row in etcd: pods, config maps, the RBAC roles themselves, and Secrets. A Secret is just the object you use to hand a password or a token to a workload. Look at one through the API and the values come back scrambled, wrapped in base64, which is exactly why people assume they're safe. They aren't. Base64 isn't encryption. It's a reversible encoding, about as secret as writing a word backwards, and one short command turns it back into the original. By default the real password lands on etcd's disk in the clear. So read access to etcd, or to a single etcd backup file sitting in some storage bucket, is read access to every credential the cluster holds, all at once.
Reading a Secret straight out of etcd
To read etcd directly you first have to reach it, and in practice that means one of three things: a foothold on a control-plane node, a copy of an etcd snapshot, or an etcd port (2379) left listening without client-certificate authentication. Getting onto the node is the next lesson's problem. Say you're already there. The etcd client, etcdctl, plus the certificates that kubeadm (the standard tool that bootstraps a cluster) already dropped on the box are all you need. Before going under the hood, look at what the API server is willing to show you on its own.
$ kubectl create secret generic db-creds \--from-literal=username=admin --from-literal=password='S3cr3t-P@ss'secret/db-creds created$ kubectl get secret db-creds -o jsonpath='{.data.password}'UzNjcjN0LVBAc3M=$ echo 'UzNjcjN0LVBAc3M=' | base64 -dS3cr3t-P@ss
That base64 string reverses with a single command, so treat what the API returned as plaintext. Now drop below the API server and read the same Secret from etcd itself. Every object lives under a key that starts with /registry, so a Secret sits at /registry/secrets/<namespace>/<name>. Pipe the raw bytes through hexdump, a tool that prints a file as hex on the left and readable characters on the right, so the human-readable parts jump out.
$ ETCDCTL_API=3 etcdctl \--endpoints=https://127.0.0.1:2379 \--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/db-creds | hexdump -C00000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|00000010 73 2f 64 65 66 61 75 6c 74 2f 64 62 2d 63 72 65 |s/default/db-cre|00000020 64 73 0a 6b 38 73 00 0a 0c 0a 02 76 31 12 06 53 |ds.k8s.....v1..S|000000d0 0a 08 70 61 73 73 77 6f 72 64 12 0b 53 33 63 72 |..password..S3cr|000000e0 33 74 2d 50 40 73 73 1a 06 0a 04 61 64 6d 69 6e |[email protected]|
Read the ASCII column down the right-hand side. The password is sitting right there in plaintext, and pulling it cost you no RBAC permission and left no entry in the Kubernetes audit log, because you never asked the API server for anything. That's the part that stings defenders. And in real incidents the attacker rarely needs a live control-plane node at all. etcd gets backed up on a schedule, those snapshots get copied off to object storage, and that storage is usually guarded far more loosely than the cluster it came from. One readable snapshot is the entire credential store in a single file. The cluster's own audit trail is blind to all of it, so your detection has to live somewhere the API server can't see.
Detecting the reach for etcd
You actually get two chances here. Most attackers who already hold a token won't bother with etcd at all. They'll just list Secrets through the API, and that path is audited. A single service account (SA, the identity a workload runs as) asking for every Secret in the cluster at once is a loud signal, the kind of thing you can alert on, like a build robot suddenly reading all the keys at three in the morning. For the raw etcd path, where the audit log can't help you, you instrument the host instead. Falco, a runtime sensor that watches Linux syscalls (the low-level requests a program makes to the operating system), fires the moment a process that has no business touching etcd's data directory or its private keys does exactly that.
$ jq -c 'select(.verb=="list" and .objectRef.resource=="secrets" and .objectRef.namespace==null)' \/var/log/kubernetes/audit.log{"verb": "list","user": { "username": "system:serviceaccount:dev:ci-runner" },"objectRef": { "resource": "secrets", "apiVersion": "v1" },"requestURI": "/api/v1/secrets?limit=500","responseStatus": { "code": 200 },"stageTimestamp": "2026-07-16T09:14:22Z"}
$ falco -r /etc/falco/rules.d/etcd.yaml09:15:02.114218 Warning Sensitive etcd file read by unexpected process(file=/etc/kubernetes/pki/etcd/server.key proc=etcdctl user=rootparent=bash container=host command=etcdctl get /registry/secrets/)
Locking the cabinet: encryption at rest
So lock the cabinet. Kubernetes can encrypt Secrets before they ever reach etcd, so what lands on disk is ciphertext instead of your password. You do it by handing the API server an EncryptionConfiguration, a short file that lists encryption providers in order, and the order is the thing people get wrong. The first provider in the list encrypts new writes. On reads, every provider is tried from the top down until one works. Put identity, the 'store it as-is' no-op, first, and you've encrypted precisely nothing. Put a real provider first and identity last, and old plaintext rows still read back fine while every new write goes encrypted. For production you back the key with a Key Management Service (KMS, an external key vault) through the KMS v2 provider. That uses envelope encryption: each Secret is locked with a local data key, and that data key is itself locked by a master key that never leaves the vault. Steal the disk and all you've got is a locked box next to a locked key. The example below uses aescbc, which needs no external service and is plenty for watching the bytes on disk actually change.
apiVersion: apiserver.config.k8s.io/v1kind: EncryptionConfigurationresources:- resources: ["secrets"]providers:- aescbc:keys:- name: key1secret: dGhpcnR5LXR3by1ieXRlLWFlcy1jYmMta2V5LTIwMjY=- identity: {}
$ sudo grep -E 'anonymous-auth|authorization-mode|encryption-provider|etcd-(ca|cert|key)file|audit-log-path' \/etc/kubernetes/manifests/kube-apiserver.yaml- --anonymous-auth=false- --authorization-mode=Node,RBAC- --encryption-provider-config=/etc/kubernetes/enc/enc.yaml- --encryption-provider-config-automatic-reload=true- --etcd-cafile=/etc/kubernetes/pki/etcd/ca.crt- --etcd-certfile=/etc/kubernetes/pki/apiserver-etcd-client.crt- --etcd-keyfile=/etc/kubernetes/pki/apiserver-etcd-client.key- --audit-log-path=/var/log/kubernetes/audit.log
The kubelet (the agent Kubernetes runs on every node) notices the changed manifest and restarts the API server pod on its own. Now the catch that bites almost everyone. Turning encryption on only affects Secrets written from this moment forward. Everything already in etcd stays exactly as plaintext as it was until you force a rewrite. So re-save every Secret to trigger a fresh encrypted write, then read the same key back out of etcd and check.
$ kubectl get secrets --all-namespaces -o json | kubectl replace -f -secret/db-creds replaced$ ETCDCTL_API=3 etcdctl --endpoints=https://127.0.0.1:2379 \--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/db-creds | hexdump -C | head -300000000 2f 72 65 67 69 73 74 72 79 2f 73 65 63 72 65 74 |/registry/secret|00000020 64 73 0a 6b 38 73 3a 65 6e 63 3a 61 65 73 63 62 |ds.k8s:enc:aescb|00000030 63 3a 76 31 3a 6b 65 79 31 3a c2 4f 9a 1e 7b 55 |c:v1:key1:.O..{U|
Try this
Work through “Locking the cabinet: encryption at rest” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: encryption at rest doesn't rewrite what's already there. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.