Lab CKS-04 — Secrets Encryption at Rest
By default a Secret is stored in etcd unencrypted. The base64 you see in kubectl get secret -o yaml is only how the API represents binary data; in etcd the value sits in the stored object as readable bytes. Anyone who can read the etcd data directory or a backup of it can read every Secret. This lab shows that, then fixes it.
Concepts: Secret Encryption and etcd Encryption.
Tested on kind v0.31 with Kubernetes v1.35.0 (the version the CKS exam used on 2026-10-13). Not re-run on v1.37; expect small differences in output there.
Verified on kind v0.31 with Kubernetes v1.35.0 and etcd 3.6.6.
1. Prerequisites & Starting State
- Working
k8s-labcluster from Lab CKAD-01. - A control plane node shell with
etcdctl, set up exactly as in Lab CKA-05:
docker exec -it k8s-lab-control-plane bash
# node
cp "$(find /var/lib/containerd -name etcdctl -type f | head -1)" /usr/local/bin/
export ETCDCTL_ENDPOINTS=https://127.0.0.1:2379
export ETCDCTL_CACERT=/etc/kubernetes/pki/etcd/ca.crt
export ETCDCTL_CERT=/etc/kubernetes/pki/etcd/server.crt
export ETCDCTL_KEY=/etc/kubernetes/pki/etcd/server.key
mkdir -p /root/backup/cks-04 && cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/backup/cks-04/2. Read a Secret from etcd
# host
kubectl create namespace enc-lab
kubectl -n enc-lab create secret generic before --from-literal=password=plain-beforeSecrets are stored under /registry/secrets/<namespace>/<name>:
# node
etcdctl get /registry/secrets/enc-lab/before --print-value-only | tr -c '[:print:]' '.'; echoThe output starts with k8s, followed by the object’s metadata, and ends with password..plain-before. The value is sitting there in clear text. This is what an attacker with an etcd snapshot sees.
3. Write the EncryptionConfiguration
# node
mkdir -p /etc/kubernetes/enc
cat > /etc/kubernetes/enc/enc.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key1
secret: $(head -c 32 /dev/urandom | base64)
- identity: {}
EOF
chmod 600 /etc/kubernetes/enc/enc.yamlProvider order is the whole point of this file:
- The first provider encrypts every new write.
- All providers are tried, in order, when reading.
So aescbc first means new Secrets are encrypted, and identity second means the existing plaintext Secrets can still be read. Reversing the two would leave everything in plaintext.
| Provider | Use |
|---|---|
identity | No encryption |
aescbc | Local key, 32 bytes, the usual exam answer |
aesgcm | Local key, faster, needs frequent rotation |
secretbox | Local key, modern cipher |
kms v2 | Key held in an external KMS, the production choice |
4. Enable It on the API Server
Edit /etc/kubernetes/manifests/kube-apiserver.yaml.
Flag:
- --encryption-provider-config=/etc/kubernetes/enc/enc.yamlVolume mount:
- name: enc
mountPath: /etc/kubernetes/enc
readOnly: trueVolume:
- name: enc
hostPath:
path: /etc/kubernetes/enc
type: Directory# node
watch crictl ps --name kube-apiserver5. Verification
A new Secret is encrypted:
# host
kubectl -n enc-lab create secret generic after --from-literal=password=plain-after# node
etcdctl get /registry/secrets/enc-lab/after --print-value-only | head -c 60 | tr -c '[:print:]' '.'; echok8s:enc:aescbc:v1:key1:;-..+..c.9...V>..`.X.A.......3}.FBz.>The prefix names the provider and the key, and the rest is ciphertext. The API still returns the value, because the API server decrypts on read:
# host
kubectl -n enc-lab get secret after -o jsonpath='{.data.password}' | base64 -dThe older Secret is still plaintext in etcd. Enabling encryption does not touch existing data:
# node
etcdctl get /registry/secrets/enc-lab/before --print-value-only | tr -c '[:print:]' '.'; echoRe-encrypt existing Secrets
Rewrite every Secret so it passes through the first provider:
# host
kubectl get secrets -A -o json | kubectl replace -f -# node
etcdctl get /registry/secrets/enc-lab/before --print-value-only | head -c 60 | tr -c '[:print:]' '.'; echoIt now starts with k8s:enc:aescbc:v1:key1: too. A task that says “ensure all existing Secrets are encrypted” is asking for this step.
6. Controlled Failure Scenario: Losing the Key
Replace the key with a different one, as if the configuration file had been regenerated:
# node
cp /etc/kubernetes/enc/enc.yaml /root/backup/cks-04/enc.yaml
sed -i "s#secret: .*#secret: $(head -c 32 /dev/urandom | base64)#" /etc/kubernetes/enc/enc.yaml
crictl stop $(crictl ps -q --name kube-apiserver)# host
kubectl -n enc-lab get secret after
kubectl get secrets -AReading a Secret encrypted with the old key fails with Internal error occurred: invalid padding on input, and listing Secrets fails for the whole cluster with unable to transform key. The data is intact in etcd but unreadable. Restore the key:
# node
cp /root/backup/cks-04/enc.yaml /etc/kubernetes/enc/enc.yaml
crictl stop $(crictl ps -q --name kube-apiserver)Two lessons follow. The encryption key must be backed up separately from etcd, since a backup without the key is useless and a backup stored with the key is not protected. And rotating a key is a sequence, not an edit:
- Add the new key as the second entry and restart the API server.
- Move the new key to the first position and restart.
- Re-encrypt everything with
kubectl replace. - Remove the old key and restart.
7. Cleanup
# host
kubectl delete namespace enc-labTo turn encryption off, first make identity the first provider, restart the API server and run the kubectl replace command so that every Secret is plaintext again. Only then remove the flag. Removing the flag while Secrets are still encrypted makes them unreadable.
Further reading
Next Lab
Proceed to Lab CKS-05 — Seccomp, AppArmor & Immutability, or return to the CKS Track.