By default, Kubernetes stores all data in etcd as plain text (base64 for Secrets is just encoding, not security). Encryption at rest ensures that even if an attacker gains access to etcd's data files, they cannot read sensitive information. This is a CKS exam staple and a production security requirement.
1. The Threat Model
What encryption at rest protects against:
| Threat | Protected? | How |
|---|---|---|
| Attacker reads etcd data files on disk | ✅ Yes | Data encrypted before writing to etcd |
| Attacker steals etcd backup files | ✅ Yes | Backup contains encrypted data |
| Attacker compromises etcd network | ✅ Partially | Data in etcd is encrypted (but etcd-to-API TLS also needed) |
Attacker has kubectl get secrets RBAC | ❌ No | API server decrypts before returning — RBAC is the control here |
| Attacker compromises the API server | ❌ No | API server holds the decryption keys (or KMS access) |
2. EncryptionConfiguration
The API server reads an EncryptionConfiguration file that defines which resources to encrypt and which encryption provider to use.
# /etc/kubernetes/enc/encryption-config.yaml
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets # Encrypt Secrets
- configmaps # Can also encrypt ConfigMaps
providers: # Tried in order for WRITES; all tried for READS
- aescbc: # ← Primary: used for all new writes
keys:
- name: key-2024-01
secret: dGhpcy1pcy1hLTMyLWJ5dGUta2V5LTEyMzQ1Ng== # 32-byte base64
- identity: {} # ← Fallback: can read unencrypted (old) data
Provider Ordering Rules
- First provider = used for writing (encrypting new/updated resources)
- All providers = tried in order for reading (decrypting existing resources)
identity: {}= reads unencrypted data (needed during migration from plaintext to encrypted)
3. Encryption Providers
| Provider | Algorithm | Key Location | Strength | Notes |
|---|---|---|---|---|
identity | None | N/A | ❌ No encryption | Default; used as fallback reader |
secretbox | XSalsa20 + Poly1305 | Config file | ✅ Strong | Recommended for local key |
aescbc | AES-256-CBC + HMAC | Config file | ✅ Strong | Most commonly used, well-understood |
aesgcm | AES-256-GCM | Config file | ⚠️ Key must be rotated frequently | Nonce reuse risk after ~200k writes per key |
kms v1 | Envelope (external) | External KMS | ✅✅ Best | Deprecated in 1.28 |
kms v2 | Envelope (external) | External KMS | ✅✅ Best | Stable in 1.29+; uses gRPC |
Local Key Providers (aescbc, secretbox)
# Generate a 32-byte key:
head -c 32 /dev/urandom | base64
# Output: dGhpcy1pcy1hLTMyLWJ5dGUta2V5LTEyMzQ1Ng==
# Use in config:
providers:
- secretbox:
keys:
- name: key-2024-01
secret: dGhpcy1pcy1hLTMyLWJ5dGUta2V5LTEyMzQ1Ng==
KMS v2 Envelope Encryption
Envelope encryption adds a layer of indirection:
# KMS v2 provider configuration:
providers:
- kms:
apiVersion: v2
name: my-kms-provider
endpoint: unix:///var/run/kms-plugin/socket.sock
timeout: 3s
| Cloud | KMS Plugin | KEK Location |
|---|---|---|
| AWS | aws-encryption-provider | AWS KMS key in your account |
| GCP | Built into GKE | Cloud KMS key |
| Azure | azure-kms-provider | Azure Key Vault |
| Self-managed | Hashicorp Vault plugin | Vault transit engine |
4. Step-by-Step: Enable Encryption at Rest
Step 1: Generate Key and Create Config
# Generate a 32-byte encryption key:
ENCRYPTION_KEY=$(head -c 32 /dev/urandom | base64)
# Create the config file:
cat > /etc/kubernetes/enc/encryption-config.yaml <<EOF
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- aescbc:
keys:
- name: key-$(date +%Y%m%d)
secret: ${ENCRYPTION_KEY}
- identity: {}
EOF
# Set restrictive permissions:
chmod 600 /etc/kubernetes/enc/encryption-config.yaml
Step 2: Configure the API Server
# Edit /etc/kubernetes/manifests/kube-apiserver.yaml:
spec:
containers:
- command:
- kube-apiserver
- --encryption-provider-config=/etc/kubernetes/enc/encryption-config.yaml
# ... other flags
volumeMounts:
- name: enc
mountPath: /etc/kubernetes/enc
readOnly: true
volumes:
- name: enc
hostPath:
path: /etc/kubernetes/enc
type: DirectoryOrCreate
The API server static Pod restarts automatically when its manifest changes.
Step 3: Re-encrypt Existing Secrets
# Wait for API server to restart, then: kubectl get secrets --all-namespaces -o json | kubectl replace -f - # This reads each Secret (decrypts with old provider or identity) # and writes it back (encrypts with new primary provider)
Step 4: Verify
# Read directly from etcd (bypass API server):
ETCDCTL_API=3 etcdctl get /registry/secrets/default/db-creds \
--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 | hexdump -C
# Should see: k8s:enc:aescbc:v1:key-20240115:... (encrypted bytes)
# NOT: {"apiVersion":"v1","data":... (plaintext JSON)
--encryption-provider-config to API server manifest, (3) Add volume/volumeMount for the config, (4) Wait for API server restart, (5) Re-encrypt existing Secrets, (6) Verify with etcdctl. Practice this flow until you can do it in under 5 minutes.
5. Key Rotation
Encryption keys should be rotated periodically. The process for local keys:
Rotation Steps
- Add new key as first in the list (it becomes the write key)
- Keep old key(s) below it (still used for reading old data)
- Restart API server
- Re-encrypt all Secrets (they get re-encrypted with new key)
- Remove old key from config (no data is encrypted with it anymore)
- Restart API server again
# After rotation, the config looks like:
providers:
- aescbc:
keys:
- name: key-2024-06 # ← NEW key (writes)
secret: bmV3LWtleS0yMDI0LTA2LXJvdGF0aW9u...
- name: key-2024-01 # ← OLD key (reads only, until re-encryption done)
secret: dGhpcy1pcy1hLTMyLWJ5dGUta2V5LTEyMzQ1Ng==
- identity: {}
Removing identity (Disabling Unencrypted Reads)
Once all Secrets are encrypted and verified, you can remove identity: {} from the providers list. This ensures the API server cannot read unencrypted data — any plaintext Secret left behind will cause a read error (which alerts you to the problem).
identity: {} after confirming ALL Secrets are encrypted. If any remain unencrypted, they become unreadable. Verify first: etcdctl get /registry/secrets/ --prefix --keys-only and spot-check several.
Summary
| Concept | Key Point |
|---|---|
| Default state | Secrets stored as plaintext (base64) in etcd — no encryption |
| EncryptionConfiguration | Defines which resources to encrypt and which provider to use |
| Provider order | First = writes; all = tried for reads |
aescbc / secretbox | Local key on disk — protects against etcd compromise, not API server compromise |
| KMS v2 | Envelope encryption — KEK never leaves external KMS. Gold standard. |
identity: {} | Read-only fallback for unencrypted data — needed during migration |
| Re-encryption | Must manually re-write existing Secrets after enabling encryption |
| Key rotation | Add new key first → re-encrypt all → remove old key |
| Verification | etcdctl get + hexdump — should show encrypted prefix, not JSON |
📝 Quiz: Encryption at Rest
Q1: You enable encryption at rest with aescbc. An attacker with kubectl get secrets RBAC access tries to read a Secret. Can they see the plaintext?
Q2: You have two providers: [aescbc, identity]. Which is used for writing new Secrets? Which for reading?
aescbc (first provider). All new Secrets are encrypted with aescbc.Reading: Both are tried in order. If data is encrypted (aescbc prefix), the aescbc provider decrypts it. If data is plaintext (old Secrets not yet re-encrypted), the identity provider reads it as-is.
Q3: You enabled encryption, restarted the API server, but forgot to re-encrypt existing Secrets. Are they encrypted now?
kubectl get secrets --all-namespaces -o json | kubectl replace -f -. This reads each Secret (through the API) and writes it back (now encrypted with the new provider).Q4: Why is KMS envelope encryption more secure than aescbc with a local key?
Q5: You need to rotate your aescbc encryption key. What's the correct order of operations?
kubectl get secrets -A -o json | kubectl replace -f -. (4) Remove old key from config. (5) Restart API server. Never remove the old key before re-encryption completes — data encrypted with it would become unreadable.Q6: How do you verify that a Secret is actually encrypted in etcd (not just base64)?
etcdctl get /registry/secrets/default/my-secret --endpoints=... --cacert=... --cert=... --key=... | hexdump -CEncrypted data starts with
k8s:enc:aescbc:v1:key-name: followed by binary. Unencrypted data shows readable JSON with {"apiVersion":"v1","data":...