The most dangerous misconception in Kubernetes security is the belief that a Secret object is secret. It is not encrypted. It is base64-encoded, which is an encoding, not a protection — anyone who can read the object can decode it in one command. And unless you have explicitly configured otherwise, that Secret is written to etcd, the cluster's key-value store, in plaintext. So the real questions are: who can read Secrets through the API, is etcd encrypted, and where does the credential actually come from? Get those three wrong and you have effectively published your database passwords to anyone with cluster read access or a copy of an etcd backup. This guide covers how to close each gap.
Base64 is not a security boundary
Prove it to yourself. A Secret's data looks opaque but decodes instantly:
# "encrypted"? No — just encoded.
kubectl get secret db-creds -o jsonpath='{.data.password}' | base64 -d
Anyone with get on Secrets in that namespace can run this. Treat the ability to read Secrets as equivalent to knowing the credentials themselves, because it is.
Turn on encryption at rest for etcd
Without configuration, the API server writes Secret objects to etcd unencrypted, so a stolen etcd snapshot — a backup file, a compromised disk, a misconfigured backup bucket — leaks every credential in the cluster. Fix this with an EncryptionConfiguration on the API server. In 2026 the right choice is the KMS v2 provider (GA since Kubernetes v1.29), which envelope-encrypts data with a key held in a cloud KMS or HSM rather than a static local key:
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets
providers:
- kms:
apiVersion: v2
name: cloud-kms
endpoint: unix:///var/run/kmsplugin/socket.sock
- identity: {} # fallback for reads of pre-existing plaintext data
Ordering matters: the first provider encrypts writes, and identity last allows reading data written before encryption was enabled. After enabling, rewrite existing Secrets so they are re-encrypted:
# Force every Secret to be rewritten through the encryption provider
kubectl get secrets --all-namespaces -o json | kubectl replace -f -
Avoid the aescbc static-key provider for anything sensitive: the key lives in a file on the control-plane node, so its security is only as good as that node's filesystem.
Stop storing secrets in the cluster at all
The cleaner architecture is to keep the source of truth in a dedicated secret manager — HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager — and sync into Kubernetes only when needed. The External Secrets Operator is the common bridge: you declare a non-sensitive ExternalSecret in Git, and the operator materializes the actual Secret at runtime by fetching from the backing store.
apiVersion: external-secrets.io/v1
kind: ExternalSecret
metadata:
name: db-creds
namespace: payments
spec:
refreshInterval: 1h
secretStoreRef:
name: vault-backend
kind: SecretStore
target:
name: db-creds # the Secret that gets created in-cluster
data:
- secretKey: password
remoteRef:
key: payments/db
property: password
The manifest you commit contains only a pointer, never the credential, so your GitOps repo stays clean and rotation happens in the secret manager without a redeploy.
When you must commit a secret: seal it
For pure GitOps shops that want the secret itself in the repo, Sealed Secrets is the safe pattern. A controller in the cluster holds a private key; you encrypt with the corresponding public key so the ciphertext is safe to commit and only the controller can decrypt it:
# Encrypt locally; the resulting SealedSecret is safe to push to Git
kubectl create secret generic db-creds --dry-run=client \
--from-literal=password='s3cr3t' -o yaml \
| kubeseal --format yaml > sealed-db-creds.yaml
The committed SealedSecret is useless to anyone without the controller's private key, which never leaves the cluster.
Lock down who can read Secrets
Encryption at rest does nothing against an over-permissioned ServiceAccount. Scope RBAC so workloads can read only the specific Secrets they need, and audit for the broad grants that quietly accumulate:
# Find subjects that can read every Secret cluster-wide — usually a mistake
kubectl get clusterrolebindings -o json \
| jq '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name'
Also mount secrets as files with defaultMode: 0400 rather than environment variables where you can — env vars leak into crash dumps, /proc, and child processes, and are trivially exposed by an SSRF or debug endpoint.
Checklist
- Encryption at rest enabled with the KMS v2 provider, not a static local key
- Existing Secrets rewritten after enabling encryption
- etcd backups encrypted and access-controlled independently
- Credentials sourced from an external manager via External Secrets, or sealed for GitOps
- RBAC scoped so no workload reads Secrets it does not use
- Automatic rotation configured in the backing store
- Secrets mounted as read-only files, not environment variables, where feasible
- Cluster audit logging captures
get/liston Secrets
How Safeguard helps
Most secret exposures are not exotic — they are a Secret committed in plaintext, an ExternalSecret misconfigured to a world-readable path, or a ServiceAccount that can read every credential in the cluster. Safeguard's IaC scanning inspects your manifests and Helm charts for hardcoded secrets, Secrets defined inline instead of referenced, and RBAC that grants broad get/list on Secrets, so the problem is caught in review rather than in an incident. The Safeguard CLI runs the same secret and misconfiguration checks in pre-commit and CI, catching a leaked key before it ever reaches the repository, and container security scanning detects credentials that have been baked into image layers — a separate and common leak path. Teams weighing broader CNAPP suites can compare approaches in Safeguard vs Prisma Cloud, and review options on the pricing page.
Base64 is not a lock. Create a free Safeguard account or read the documentation to find exposed secrets before an attacker does.