Encrypting Kubernetes Secrets at rest in etcd

Kubernetes Secrets are only base64 by default. Turn on an EncryptionConfiguration and rotate the key without downtime.

Feb 4, 2026·Updated ·5 min readAdvanced·By SecOpsLog · documentation-verified
bash — what etcd holds for a Secret on a default cluster
ETCDCTL_API=3 etcdctl get /registry/secrets/shop/db --cacert=/etc/kubernetes/pki/etcd/ca.crt --cert=/etc/kubernetes/pki/etcd/server.crt --key=/etc/kubernetes/pki/etcd/server.key | strings | head -5
/registry/secrets/shop/db
k8s
password
c3VwZXItc2VjcmV0
echo c3VwZXItc2VjcmV0 | base64 -d -> super-secret

A Secret is base64-encoded in etcd, and base64 is an encoding. Whoever has the etcd data (a snapshot in an S3 bucket, a backup on a jump host, a control-plane disk image) has every password, token and TLS key in the cluster, without ever calling the API server. Encryption at rest closes exactly that path: the API server encrypts the value before it writes to etcd and decrypts on read, so a copy of etcd is ciphertext plus a key the copy does not contain. It does nothing about a user with RBAC permission to get secrets; that path is governed by RBAC and audited by the audit log.

Encryption at rest for Secrets

The API server is the only component that sees plaintext. Existing objects stay in whatever form they were written in until they are rewritten, which is why turning encryption on is a migration, not a flag.

API write Secret object EncryptionConfiguration providers: aescbc / kms / identity etcd ciphertext ! Without encryption base64 in etcd = plaintext etcd backup = secret dump anyone with etcd access wins 1 Enable providers aescbc then kms preferred identity last for rollout restart apiserver 2 Rewrite + verify kubectl replace all Secrets etcdctl get → ciphertext then rotate / add KMS Base64 is encoding, not encryption. Prove ciphertext in etcd before you call it done. Secret → EncryptionConfiguration → etcd ciphertext
Risk — plaintext etcdEncrypt — providersVerify — rewrite

Choose the provider before writing the config

Encryption providers

ProviderWhat it doesWhere the key livesUse it when
identityno encryption; the defaultnowhereonly as the last entry, to keep reading legacy plaintext during a migration
secretboxXSalsa20-Poly1305, 32-byte keyin the config file on each control-plane hosta quick improvement over plaintext; not where key custody is reviewed
aescbc / aesgcmAES-CBC (weaker) or AES-GCM (needs key rotation every ~200k writes)in the config filelegacy configurations; prefer secretbox or kms if staying local
kms v2envelope encryption: a DEK per encryption, wrapped by a KEK in an external KMSin the KMS (AWS KMS, GCP KMS, Azure Key Vault, Vault) via a pluginproduction; stable since v1.29, key rotation and audit belong to the KMS

The order of providers in the file matters twice over: the first provider encrypts new writes, and every listed provider is tried in turn when reading. Keeping identity last during the migration lets the API server read Secrets that were written before encryption was enabled; removing it is the final step, not the first.

/etc/kubernetes/enc/enc.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources: [secrets]
providers:
- secretbox:
keys:
- name: key1
secret: <32 random bytes, base64: head -c 32 /dev/urandom | base64>
- identity: {} # read-only fallback for objects written before encryption
# kube-apiserver flags (static pod manifest): mount the file read-only and pass
# --encryption-provider-config=/etc/kubernetes/enc/enc.yaml
# --encryption-provider-config-automatic-reload=true
A local key encrypts the backup with a key stored next to it
With secretbox or aescbc the key sits in a file on every control-plane host, so a disk image of that host contains both etcd and the key. That still defeats the S3-bucket-of-snapshots case, but it is not key custody. The kms v2 provider keeps the key in a service whose access is separately controlled and logged, which is the difference an auditor will ask about.

Rewrite what already exists, then prove it

Once the API server restarts with the file, new Secrets are encrypted. Existing ones are still plaintext in etcd until something writes them again. The documented migration is a replace of every Secret through the API server, which reads each one and writes it back unchanged, now encrypted. The command is safe to repeat; a conflict on a Secret that changed mid-run is an error to retry, not a failure. On a large cluster, run it per namespace so a failure is easy to resume.

bash — rewrite every Secret, then read etcd again
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
secret/db replaced
secret/api-token replaced
… one line per Secret; rerun on conflicts
ETCDCTL_API=3 etcdctl get /registry/secrets/shop/db ... | hexdump -C | head -4
00000020 31 0a 6b 38 73 3a 65 6e 63 3a 73 65 63 72 65 74 |1.k8s:enc:secret|
00000030 62 6f 78 3a 76 31 3a 6b 65 79 31 3a c7 6c e7 d3 |box:v1:key1:.l..|
k8s:enc:secretbox:v1:key1: names the provider and the key that encrypted this object

The prefix is the proof. k8s:enc:<provider>:v1:<keyname>: at the start of the stored value says which provider and which key were used, and a value with no such prefix is still plaintext. A script that lists Secrets whose stored form lacks the prefix is the check that the migration actually finished, because the API server will happily keep serving the unencrypted ones.

Rotating the key without downtime

Rotation follows the same read-many, write-one rule. Add the new key as the first entry so new writes use it, keep the old key listed so existing objects still decrypt, and make sure every API server has loaded the new file before rewriting: with --encryption-provider-config-automatic-reload=true the file is polled every minute and no restart is needed, and the metric apiserver_encryption_config_controller_automatic_reload_last_timestamp_seconds tells you when each server picked it up. Then run the replace again, back up the new key, and only then remove the old one. Removing a key while any Secret is still encrypted with it makes those Secrets unreadable, and no amount of restarting fixes that.

enc.yaml (rotation, two edits)
# edit 1: new key first, old key kept for reads; wait for reload on every apiserver,
# then: kubectl get secrets -A -o json | kubectl replace -f -
providers:
- secretbox:
keys:
- name: key2
secret: <new 32-byte key>
- name: key1
secret: <old key>
- identity: {}
# edit 2: after the rewrite completed and key2 is backed up
providers:
- secretbox:
keys:
- name: key2
secret: <new 32-byte key>
# identity removed too, once no plaintext objects remain
What encryption at rest changes, and what it leaves
Now protected
etcd snapshots and backups
Control-plane disk images (with kms)
Direct etcd reads by a node-level attacker
Unchanged
Anyone with RBAC get/list on Secrets
Secrets mounted into Pods on the node
Values already copied into logs or Git

The unchanged column is where the next controls live: least-privilege RBAC for who may read a Secret through the API, and External Secrets or Vault so the authoritative copy is outside the cluster and the object in etcd is a short-lived projection of it.

Related posts

Quick reference