Secrets Management
On this page
- Kubernetes Secret Fundamentals
- Secret Types Reference
- Consuming Secrets: Env vs Volume
- RBAC for Secrets
- etcd Encryption at Rest
- External Secret Stores
- External Secrets Operator (ESO)
- Secrets Store CSI Driver
- Sealed Secrets (GitOps)
- IRSA & Workload Identity
- Secret Rotation Patterns
- Secret Scanning in CI/CD
- Anti-Patterns
- Metrics & Alerts
- Best Practices
Coverage checklist
- Kubernetes Secret: base64 encoding ≠ encryption callout
- Secret types table (13 types)
- immutable secrets (1.21 GA)
- env var vs volume mount trade-offs
- envFrom vs individual secretKeyRef
- Volume mount: auto-updated rotation, tmpfs backing
- RBAC: list secrets = read all values danger
- etcd encryption at rest: aesgcm, aescbc, secretbox, KMS v1/v2
- KMS v2 (1.27 stable): envelope encryption diagram
- Key rotation: re-encrypt all secrets procedure
- External stores: HashiCorp Vault, AWS SSM, AWS Secrets Manager, GCP Secret Manager, Azure Key Vault
- ESO: ExternalSecret, SecretStore, ClusterSecretStore
- ESO data/dataFrom, refreshInterval, creationPolicy
- Secrets Store CSI Driver: SecretProviderClass, sync to K8s Secret
- CSI driver: pod must be running to refresh secret
- Sealed Secrets: controller + kubeseal workflow
- Sealed Secrets GitOps pattern
- IRSA (AWS): ServiceAccount annotation → AWS IAM role
- GCP Workload Identity Federation
- Azure AD Workload Identity
- Secret rotation: zero-downtime rolling restart pattern
- Reloader (stakater) for auto-restart on secret change
- gitsecrets, truffleHog, detect-secrets scanning
- Anti-patterns: secrets in env vars, plain-text in ConfigMap, no rotation, broad RBAC
- 5 metrics, 4 alerts, 5 runbooks, 8 best practices
Kubernetes Secret Fundamentals
A Kubernetes Secret is an API object that stores small pieces of sensitive data such as passwords, tokens, and TLS certificates. Secrets are base64-encoded in etcd by default — base64 is encoding, not encryption. Anyone with read access to the Secret object or direct etcd access can trivially decode the values.
echo "c3VwZXJzZWNyZXQ=" | base64 -d → supersecret. Kubernetes Secrets provide only namespace isolation (via RBAC) and API server access control. Without etcd encryption at rest and tight RBAC, Secrets are effectively plain text to anyone with cluster access. See etcd Encryption at Rest for the first layer of real protection.
Creating Secrets
# Imperative: from literal values (base64-encoded automatically)
kubectl create secret generic db-credentials \
--from-literal=username=myapp \
--from-literal=password='S!B\*d$zDsb' \
-n production
# Imperative: from files (file content becomes the value)
kubectl create secret generic tls-secret \
--from-file=tls.crt=./server.crt \
--from-file=tls.key=./server.key \
-n production
# Declarative YAML — values must be base64-encoded manually
# echo -n "mysecret" | base64 → bXlzZWNyZXQ=
apiVersion: v1
kind: Secret
metadata:
name: db-credentials
namespace: production
annotations:
reloader.stakater.com/match: "true" # trigger pod restart on change (Reloader)
type: Opaque # most common type — arbitrary key/value pairs
immutable: true # GA 1.21: prevents accidental modification; requires delete+recreate to update
data:
username: bXlhcHA= # base64("myapp")
password: U0FCXCpkJHpEc2I= # base64("S!B\*d$zDsb")
stringData: # plaintext convenience field — K8s encodes it
jdbc-url: "jdbc:postgresql://db:5432/mydb?user=myapp&password=S!B\\*d$zDsb"
# stringData is write-only; reading the secret returns data (base64) not stringData
Secret Types Reference
| Type | Purpose | Required Keys |
|---|---|---|
Opaque | Arbitrary user-defined data; most common type | None (any key/value) |
kubernetes.io/service-account-token | Legacy SA token (pre-1.22); prefer bound projected tokens | Auto-populated by controller |
kubernetes.io/dockercfg | Legacy Docker config (deprecated) | .dockercfg |
kubernetes.io/dockerconfigjson | Docker registry auth (imagePullSecrets) | .dockerconfigjson |
kubernetes.io/basic-auth | HTTP Basic Auth credentials | username, password |
kubernetes.io/ssh-auth | SSH key pair | ssh-privatekey |
kubernetes.io/tls | TLS certificate and private key | tls.crt, tls.key |
bootstrap.kubernetes.io/token | Node bootstrap token (kubeadm) | token-id, token-secret, usage-* |
imagePullSecret for Private Registries
# Create imagePullSecret from docker login credentials
kubectl create secret docker-registry my-registry-creds \
--docker-server=registry.example.com \
--docker-username=robot-account \
--docker-password="$REGISTRY_TOKEN" \
--docker-email=noreply@example.com \
-n production
# Reference in pod spec
spec:
imagePullSecrets:
- name: my-registry-creds
containers:
- name: app
image: registry.example.com/myapp:latest
# Or attach to ServiceAccount so all pods using it inherit the secret
apiVersion: v1
kind: ServiceAccount
metadata:
name: app-sa
namespace: production
imagePullSecrets:
- name: my-registry-creds
Consuming Secrets: Env Vars vs Volume Mounts
Environment Variables
env:
# Method 1: individual key reference
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials
key: password
optional: false # pod fails to start if secret/key missing
# Method 2: envFrom — all keys from a secret become env vars
envFrom:
- secretRef:
name: db-credentials # ALL keys in secret become env vars
optional: false
- configMapRef:
name: app-config
Volume Mounts
volumes:
- name: secret-vol
secret:
secretName: db-credentials
defaultMode: 0400 # read-only for owner only
items: # optional: select specific keys and rename paths
- key: password
path: db/password # mounted as /secrets/db/password
mode: 0400
containers:
- name: app
volumeMounts:
- name: secret-vol
mountPath: /secrets
readOnly: true
Env Var vs Volume Mount Trade-offs
| Aspect | Environment Variable | Volume Mount |
|---|---|---|
| Exposure risk | High — env vars visible in /proc/self/environ, process listings, crash dumps, logs if accidentally printed | Lower — files not typically dumped in logs |
| Child process inheritance | Automatically inherited by all child processes and subshells | Only accessible if child process reads the file |
| Auto-rotation | None — pod must restart to pick up new value | kubelet updates file content when Secret changes (within ~1min); app must re-read file |
| Memory backing | Kernel env block (persisted) | tmpfs by default — not written to node disk |
| Large secrets | Limited by max env var size and total env block | Supported up to Secret size limit (1MB) |
| Recommendation | Avoid for sensitive values; acceptable for non-sensitive config | Preferred for sensitive values like passwords, tokens, certs |
kubectl describe pod output (if not using secretKeyRef), in /proc/<pid>/environ (readable by root processes), in core dumps, in application startup logs if the app logs its environment (common during debugging), and in CI/CD pipeline logs if mishandled. Volume-mounted files have a smaller blast radius for accidental exposure.
Projected Volume (Multiple Sources)
# Combine secrets, configmaps, service account token, and downward API in one volume
volumes:
- name: projected
projected:
sources:
- secret:
name: db-credentials
items:
- key: password
path: db-password
- configMap:
name: app-config
- serviceAccountToken:
path: token
expirationSeconds: 3600 # bound token, 1h expiry
audience: "https://kubernetes.default.svc"
- downwardAPI:
items:
- path: namespace
fieldRef:
fieldPath: metadata.namespace
RBAC for Secrets
Secrets require the most restrictive RBAC posture of any Kubernetes object. Key principles:
- Never grant
liston secrets cluster-wide.listreturns all secret objects including theirdatafield values. A subject withliston secrets in a namespace can read every secret value in that namespace. - Grant
geton specific secrets by name where possible usingresourceNames. - Audit secret access regularly — secret reads should be infrequent and expected.
# Minimal: allow a specific workload to read only its own secrets
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
name: app-secret-reader
namespace: production
rules:
- apiGroups: [""]
resources: ["secrets"]
resourceNames: ["db-credentials", "api-keys"] # only these secrets
verbs: ["get"] # no list, no watch, no update
---
# What NOT to do:
rules:
- apiGroups: [""]
resources: ["secrets"]
verbs: ["get", "list", "watch"] # list = read ALL secret values in namespace
view role does not grant access to Secrets. Do not add secrets to it. If developers need to read a specific secret for debugging, create a time-limited RoleBinding to a minimal role, then remove it. Use audit logs to track who read what.
etcd Encryption at Rest
etcd stores all Kubernetes objects including Secrets. Without encryption at rest, anyone who accesses the etcd data files (disk snapshots, backup tapes, cloud provider storage) can read all secret values. Encryption at rest protects against:
- Stolen etcd backup files
- Cloud provider support access to underlying storage
- Physical disk theft in on-premises deployments
It does not protect against: compromised API server (which decrypts on read), compromised etcd network connection (use TLS for that), or a compromised kube-apiserver process.
Encryption Providers
| Provider | Algorithm | Key Location | Recommendation |
|---|---|---|---|
identity | None (plain text) | N/A | Never use — default if no config |
secretbox | XSalsa20 + Poly1305 | Config file on master | Good — fast, secure, 32-byte key |
aesgcm | AES-GCM 128/256 bit | Config file on master | Good — standard AES; 200k ops/key limit |
aescbc | AES-CBC 128/256 bit | Config file on master | Acceptable — older default; use secretbox or aesgcm instead |
kms (v1) | AES-CBC wrapped by KMS | External KMS (AWS KMS, GCP KMS, Vault) | Best — envelope encryption; key never on disk |
kms (v2, stable 1.29) | AES-GCM wrapped by KMS | External KMS | Best — improved performance; DEK caching; key rotation without re-encrypt |
Envelope Encryption Architecture (KMS)
WRITE path (secret creation):
kube-apiserver → generate random DEK (data encryption key, 32 bytes)
→ encrypt secret data with DEK (AES-GCM)
→ call KMS provider plugin → KMS (AWS KMS / GCP KMS / Vault)
→ KMS encrypts DEK with KEK (key encryption key) → returns encrypted DEK
→ store: {encrypted_DEK + encrypted_data} in etcd
→ DEK is NOT stored anywhere in plaintext
READ path (secret retrieval):
kube-apiserver → read {encrypted_DEK + encrypted_data} from etcd
→ call KMS → decrypt DEK with KEK
→ decrypt data with DEK
→ return plaintext secret to caller
→ (KMS v2: DEK cached in memory — avoids KMS call on every read)
KEY ROTATION:
KMS v1: re-encrypt all secrets (expensive full re-encrypt pass)
KMS v2: rotate KEK in KMS; old DEKs automatically re-wrapped on next write
EncryptionConfiguration
apiVersion: apiserver.config.k8s.io/v1
kind: EncryptionConfiguration
resources:
- resources:
- secrets # encrypt Secret objects
- configmaps # optionally encrypt ConfigMaps too
providers:
- kms: # first provider = used for NEW writes
name: aws-kms
endpoint: unix:///var/run/kmsplugin/socket.sock # KMS plugin socket
cachesize: 1000 # DEK cache size (KMS v2)
timeout: 3s
- identity: {} # fallback for reading old unencrypted data
# IMPORTANT: keep identity last to read legacy objects
# Remove identity only after all secrets are re-encrypted
# Apply encryption config to kube-apiserver
# Add flag: --encryption-provider-config=/etc/kubernetes/encryption-config.yaml
# After enabling, re-encrypt all existing secrets (they were written unencrypted)
kubectl get secrets --all-namespaces -o json | \
kubectl replace -f -
# This reads all secrets (decrypts with identity provider) and rewrites them
# (encrypts with the first provider = KMS)
# Verify a secret is encrypted in etcd
ETCDCTL_API=3 etcdctl get \
--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 \
/registry/secrets/default/my-secret | xxd | head
# Should show binary/encrypted data, not readable YAML
External Secret Stores
The most secure pattern is to never store secrets in Kubernetes etcd at all. External secret stores provide centralized management, fine-grained access control, rotation automation, and full audit trails.
| Store | Cloud | Key Features | K8s Integration |
|---|---|---|---|
| HashiCorp Vault | Any (self-hosted or HCP) | Dynamic secrets, lease-based TTLs, PKI, database credential generation, full audit log | ESO, CSI driver, Vault Agent Injector, Vault Secrets Operator |
| AWS Secrets Manager | AWS | Automatic rotation (Lambda), cross-account access, resource-based policies, version staging | ESO, CSI driver + ASCP, IRSA for auth |
| AWS SSM Parameter Store | AWS | SecureString with KMS, hierarchical paths, lower cost than Secrets Manager for simple key/value | ESO, CSI driver + ASCP, IRSA for auth |
| GCP Secret Manager | GCP | Version management, CMEK, VPC Service Controls, regional replication | ESO, CSI driver, Workload Identity for auth |
| Azure Key Vault | Azure | HSM-backed, certificates + keys + secrets, Azure AD integration | ESO, CSI driver (AKV provider), Azure Workload Identity |
External Secrets Operator (ESO)
External Secrets Operator (ESO) is a Kubernetes operator that reads from external secret stores and creates Kubernetes Secrets from them. It runs as a controller and syncs secrets on a configurable interval.
External Secret Store ESO Controller Kubernetes
(Vault / AWS SM / GCP SM) (Operator) Cluster
───────────────────── ────────────── ──────────────────
reads SecretStore → SecretStore/ClusterSecretStore CRD
polls ExternalSecret → ExternalSecret CRD
reads secret value ← calls store API
creates/updates K8s → Secret (managed by ESO)
Secret every
refreshInterval
SecretStore — defines the external backend
# Namespace-scoped SecretStore
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
name: aws-secrets-manager
namespace: production
spec:
provider:
aws:
service: SecretsManager
region: us-east-1
auth:
jwt: # IRSA authentication via projected SA token
serviceAccountRef:
name: eso-sa # SA annotated with IAM role ARN
---
# Cluster-scoped (accessible from all namespaces)
apiVersion: external-secrets.io/v1beta1
kind: ClusterSecretStore
metadata:
name: vault-backend
spec:
provider:
vault:
server: "https://vault.example.com"
path: "secret"
version: "v2"
auth:
kubernetes:
mountPath: "kubernetes"
role: "eso-role"
serviceAccountRef:
name: eso-sa
namespace: external-secrets
ExternalSecret — maps store keys to Kubernetes Secret
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
name: db-credentials
namespace: production
spec:
refreshInterval: "1h" # sync every hour; use "0" to disable auto-sync
secretStoreRef:
name: aws-secrets-manager # references the SecretStore above
kind: SecretStore # or ClusterSecretStore
target:
name: db-credentials # name of the K8s Secret to create/update
creationPolicy: Owner # ESO owns the secret; deleted when ExternalSecret is deleted
# creationPolicy: Merge # merge into existing secret (ESO adds keys, doesn't own)
# creationPolicy: None # ESO does not create the secret (external process creates it)
deletionPolicy: Retain # keep K8s Secret if ExternalSecret is deleted
# deletionPolicy: Delete # delete K8s Secret when ExternalSecret is deleted
template: # optional: template the secret format
type: kubernetes.io/dockerconfigjson
data:
.dockerconfigjson: |
{"auths":{"registry.example.com":{"auth":"{{ .token | b64enc }}"}}}
data:
- secretKey: password # key in the resulting K8s Secret
remoteRef:
key: prod/db/credentials # path in the external store
property: password # specific field within the store entry (JSON)
version: "AWSCURRENT" # version/stage (AWS Secrets Manager)
- secretKey: username
remoteRef:
key: prod/db/credentials
property: username
dataFrom: # import all keys from a store path
- extract:
key: prod/app/config # all JSON keys become Secret data keys
ESO Status and Debugging
# Check sync status
kubectl get externalsecret -n production
# NAME STORE REFRESH-INTERVAL STATUS READY
# db-credentials aws-secrets-manager 1h SecretSynced True
kubectl describe externalsecret db-credentials -n production
# Shows: last sync time, conditions, errors
# Force immediate refresh
kubectl annotate externalsecret db-credentials \
force-sync=$(date +%s) -n production
Secrets Store CSI Driver
The Secrets Store CSI Driver (SSCD) mounts secrets from external stores directly into pods as files, bypassing Kubernetes Secrets entirely. Secrets are fetched at pod startup, mounted as tmpfs volumes, and optionally synced to Kubernetes Secrets for env var consumption.
Pod startup sequence: 1. Scheduler places pod on node 2. kubelet calls CSI driver for the SecretProviderClass volume 3. CSI driver authenticates to external store (Vault/AWS SM) using pod's SA token 4. CSI driver fetches secrets and mounts them as tmpfs files in the pod 5. Pod starts with secret files available at /mnt/secrets/ 6. (Optional) CSI driver creates K8s Secret for env var consumption ROTATION: CSI driver re-fetches secrets on each pod rotation period (default: 2 minutes) File content is updated in place — no pod restart needed for file changes K8s Secret sync is also updated — Reloader needed for env var rotation
SecretProviderClass
apiVersion: secrets-store.csi.x-k8s.io/v1
kind: SecretProviderClass
metadata:
name: aws-secrets
namespace: production
spec:
provider: aws
parameters:
objects: |
- objectName: "prod/db/credentials"
objectType: "secretsmanager"
jmesPath:
- path: username
objectAlias: db-username
- path: password
objectAlias: db-password
- objectName: "/prod/api/key"
objectType: "ssmparameter"
objectAlias: api-key
secretObjects: # optionally sync to K8s Secret for env var use
- data:
- key: password
objectName: db-password
- key: username
objectName: db-username
secretName: db-credentials-synced # K8s Secret created/updated by CSI driver
type: Opaque
# Reference in pod spec
volumes:
- name: secrets-store
csi:
driver: secrets-store.csi.k8s.io
readOnly: true
volumeAttributes:
secretProviderClass: aws-secrets
containers:
- name: app
volumeMounts:
- name: secrets-store
mountPath: /mnt/secrets
readOnly: true
env: # use synced K8s Secret for env vars if needed
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-credentials-synced
key: password
Sealed Secrets (GitOps)
Sealed Secrets (Bitnami) allows encrypting Kubernetes Secrets so they can be safely committed to Git repositories. The controller holds the private decryption key; the encrypted SealedSecret resource is safe to store in source control.
Developer workflow:
1. Developer creates a plain K8s Secret YAML locally
2. kubeseal CLI fetches controller's public cert from the cluster
3. kubeseal encrypts the secret: SealedSecret YAML (safe for Git)
4. Developer commits SealedSecret.yaml to Git repo
5. GitOps operator (ArgoCD/Flux) applies SealedSecret to cluster
6. Sealed Secrets controller decrypts → creates K8s Secret
Security model:
- SealedSecret is encrypted with the controller's RSA public key
- Only the controller (holding the private key) can decrypt
- Encryption is namespaced (by default): a SealedSecret for "production"
cannot be decrypted/used in "staging"
- Controller private key must be backed up separately
# Install kubeseal CLI
brew install kubeseal
# Fetch the controller's public key (one-time or periodically)
kubeseal --fetch-cert \
--controller-name=sealed-secrets-controller \
--controller-namespace=kube-system \
> pub-sealed-secrets.pem
# Create a plain secret and pipe to kubeseal
kubectl create secret generic db-credentials \
--from-literal=password=mysecret \
--dry-run=client -o yaml | \
kubeseal --cert pub-sealed-secrets.pem \
--scope namespace-wide \
> sealed-db-credentials.yaml
# Commit sealed-db-credentials.yaml to Git — safe to store
# DO NOT commit the plain secret YAML
# Apply to cluster (ArgoCD/Flux does this automatically)
kubectl apply -f sealed-db-credentials.yaml
# SealedSecret format (safe to commit to Git)
apiVersion: bitnami.com/v1alpha1
kind: SealedSecret
metadata:
name: db-credentials
namespace: production
spec:
encryptedData:
password: AgBy3i4OJSWK+PiTySYZZA9rO43cGDEq... # encrypted value
template:
metadata:
name: db-credentials
namespace: production
type: Opaque
kubectl get secret sealed-secrets-key -n kube-system -o yaml > sealed-secrets-key-backup.yaml. Store this backup in a secure vault, not in Git.
IRSA & Workload Identity
IRSA (IAM Roles for Service Accounts) and Workload Identity eliminate the need for static cloud credentials entirely. Instead of storing AWS access keys or GCP service account keys as Kubernetes Secrets, pods authenticate to cloud services using their Kubernetes ServiceAccount identity, which is federated to a cloud IAM role.
AWS IRSA
Pod (SA: my-app)
├── Projected volume: /var/run/secrets/eks.amazonaws.com/serviceaccount/token
│ (audience-bound token, 1h expiry)
└── STS call: AssumeRoleWithWebIdentity(token, role_arn)
↓
AWS IAM validates: token signed by cluster OIDC provider?
↓
Returns temporary credentials (AccessKeyId, SecretAccessKey, SessionToken)
↓
AWS SDK uses temp creds automatically — no static keys stored anywhere
# 1. Create IAM role with trust policy allowing the SA (done once by platform team)
# Trust policy: allow sts:AssumeRoleWithWebIdentity from specific SA
# 2. Annotate the ServiceAccount with the IAM role ARN
kubectl annotate serviceaccount my-app \
eks.amazonaws.com/role-arn=arn:aws:iam::123456789012:role/my-app-role \
-n production
# 3. No other changes needed — AWS SDK automatically detects IRSA token
apiVersion: v1
kind: ServiceAccount
metadata:
name: my-app
namespace: production
annotations:
eks.amazonaws.com/role-arn: "arn:aws:iam::123456789012:role/my-app-role"
eks.amazonaws.com/token-expiration: "3600" # token expiry seconds (default 86400)
GCP Workload Identity
# Bind K8s SA to GCP Service Account
gcloud iam service-accounts add-iam-policy-binding \
my-gsa@my-project.iam.gserviceaccount.com \
--role=roles/iam.workloadIdentityUser \
--member="serviceAccount:my-project.svc.id.goog[production/my-app]"
# Annotate K8s SA
kubectl annotate serviceaccount my-app \
iam.gke.io/gcp-service-account=my-gsa@my-project.iam.gserviceaccount.com \
-n production
Azure Workload Identity
# Azure AD Workload Identity (AZWI)
# Create federated credential between K8s SA and Azure Managed Identity
az identity federated-credential create \
--name my-app-federated \
--identity-name my-app-identity \
--resource-group my-rg \
--issuer "https://oidc.prod-aks.azure.com/my-cluster-id/" \
--subject "system:serviceaccount:production:my-app"
# Annotate K8s SA
kubectl annotate serviceaccount my-app \
azure.workload.identity/client-id="$CLIENT_ID" \
-n production
Secret Rotation Patterns
Secrets should be rotated regularly: database passwords, API keys, and TLS certificates all have recommended rotation windows. The challenge in Kubernetes is rotating without downtime.
Zero-Downtime Rotation with Rolling Restart
Step 1: Update the external store (or K8s Secret) with the new credential value
(keep old value accessible during transition — dual credentials)
Step 2: ESO / CSI driver syncs new value to K8s Secret (within refreshInterval)
OR: update K8s Secret directly
Step 3: Trigger rolling restart of affected Deployments:
kubectl rollout restart deployment/my-app -n production
(new pods start with new secret; old pods still run with old)
Step 4: Verify new pods are healthy and processing correctly
Step 5: Revoke old credential in the external store / database
Step 6: Remove dual-credential support from application if applicable
Reloader — Automatic Restart on Secret Change
# Install Stakater Reloader
helm upgrade --install reloader stakater/reloader \
--namespace reloader \
--create-namespace
# Annotate Deployment to auto-restart when referenced secret changes
apiVersion: apps/v1
kind: Deployment
metadata:
name: my-app
annotations:
reloader.stakater.com/auto: "true" # watch all secrets/configmaps in pod spec
# OR: only watch specific secrets:
secret.reloader.stakater.com/reload: "db-credentials,api-keys"
cert-manager for TLS Certificate Rotation
# cert-manager automatically rotates TLS certs before expiry
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: my-tls-cert
namespace: production
spec:
secretName: my-tls-secret # cert-manager creates and rotates this Secret
duration: 2160h # 90 days
renewBefore: 360h # renew 15 days before expiry
issuerRef:
name: letsencrypt-prod
kind: ClusterIssuer
dnsNames:
- my-app.example.com
Secret Scanning in CI/CD
Preventing secrets from being committed to source control is far easier than responding to a secret leak. Implement scanning at multiple stages of the development lifecycle.
Pre-commit with detect-secrets
# Install detect-secrets
pip install detect-secrets
# Generate baseline (current known false positives)
detect-secrets scan > .secrets.baseline
# In .pre-commit-config.yaml:
# repos:
# - repo: https://github.com/Yelp/detect-secrets
# hooks:
# - id: detect-secrets
# args: ['--baseline', '.secrets.baseline']
# Run manually
detect-secrets scan --baseline .secrets.baseline
git-secrets (AWS Labs)
# Install and configure
git secrets --install # install hooks in current repo
git secrets --register-aws # add AWS secret patterns (access keys, etc.)
# Scan entire repo history
git secrets --scan-history
TruffleHog — deep history scan
# Scan entire git history for high-entropy strings and known secret patterns
trufflehog git https://github.com/myorg/myrepo \
--only-verified \
--json | jq '.SourceMetadata.Data.Git.commit'
# In CI/CD pipeline (GitHub Actions example):
# - uses: trufflesecurity/trufflehog@main
# with:
# path: ./
# base: ${{ github.event.repository.default_branch }}
# head: HEAD
Kubernetes Secret scanning
# Find secrets with empty/trivial values
kubectl get secrets --all-namespaces -o json | \
jq -r '.items[] | select(.data | length == 0) | "\(.metadata.namespace)/\(.metadata.name)"'
# Find secrets not referenced by any pod (orphaned secrets)
# Use kube-score or Trivy Operator for automated checks
# Check for secrets in ConfigMaps (should never happen)
kubectl get configmaps --all-namespaces -o json | \
jq -r '.items[].data | to_entries[] | select(.value | test("password|secret|token|key"; "i")) | .key'
Anti-Patterns
| Anti-Pattern | Risk | Fix |
|---|---|---|
| Secrets in environment variables | Leak via logs, process listing, crash dumps, child processes | Use volume mounts; or external store with CSI driver |
| Secrets in ConfigMaps | ConfigMaps are not Secrets — no RBAC separation; shown in kubectl describe | Always use Secret type; consider etcd encryption |
| Secrets committed to Git (plain text) | Permanent exposure — git history cannot be safely purged | Use Sealed Secrets for GitOps; never commit plain secrets |
| Secrets in Dockerfile or image layers | Image layers are permanent; anyone with image pull can read the secret | Build-time secrets via --secret BuildKit flag; never RUN with secret values |
| Static cloud credentials as K8s Secrets | Long-lived, hard to rotate, leaked if etcd compromised | Use IRSA/Workload Identity — no static credentials needed |
| Broad RBAC on secrets (list/watch all) | Any compromised SA can enumerate all secret values in the namespace | Least-privilege: get on specific secrets by resourceNames |
| No secret rotation (set and forget) | Leaked credentials have indefinite blast radius | Automated rotation via Vault TTLs, ESO refresh, cert-manager; enforce max TTL policy |
| Using default ServiceAccount for pods that need secrets | Any exploit in any pod using default SA can read all its secrets | Dedicated SA per workload with minimal secret access |
Metrics & Alerts
Key Metrics
| Metric | Source | What It Tells You |
|---|---|---|
apiserver_storage_transformation_operations_total{status,transformation_type} | kube-apiserver | Encryption/decryption operation counts; errors indicate KMS plugin failures |
apiserver_storage_transformation_duration_seconds | kube-apiserver | KMS encryption latency; P99 spike indicates KMS availability issue |
externalsecrets_sync_calls_total{name,namespace,status} | ESO | ESO sync attempts and failures per ExternalSecret |
externalsecrets_sync_duration_seconds | ESO | Sync latency to external store |
vault_secret_lease_duration_seconds | Vault | Time until secret leases expire — detect rotation gaps |
Alerts
groups:
- name: secrets.rules
rules:
- alert: ExternalSecretSyncFailing
expr: |
rate(externalsecrets_sync_calls_total{status="error"}[5m]) > 0
for: 5m
annotations:
summary: "ExternalSecret {{ $labels.name }}/{{ $labels.namespace }} failing to sync"
description: "Check ESO logs and external store connectivity. Secret may be stale."
labels:
severity: high
- alert: KMSEncryptionLatencyHigh
expr: |
histogram_quantile(0.99,
rate(apiserver_storage_transformation_duration_seconds_bucket{transformation_type="encrypt"}[5m])
) > 0.5
for: 2m
annotations:
summary: "KMS encryption P99 > 500ms — API server latency degraded"
labels:
severity: warning
- alert: SecretReadByUnexpectedServiceAccount
# Implement via audit log stream:
# verb=get, resource=secrets, user.username NOT IN allowlist
annotations:
summary: "Secret read by unexpected principal: {{ $labels.user }}"
labels:
severity: high
- alert: TLSCertificateExpiringSoon
expr: |
(certmanager_certificate_expiration_timestamp_seconds - time()) / 86400 < 14
annotations:
summary: "TLS certificate expires in < 14 days: {{ $labels.name }}"
labels:
severity: warning
Runbooks
- ExternalSecret stuck in error state: Check
kubectl describe externalsecret <name> -n <ns>for the error condition. Common causes: ESO SA lacks IAM permission to read the secret (add IAM policy), secret path is wrong (typo inkey), external store is unavailable (check VPC connectivity, firewall). Force resync:kubectl annotate externalsecret <name> force-sync=$(date +%s) -n <ns>. - KMS plugin unavailable — API server degraded: Kubernetes will queue writes and fail reads that require decryption. New secrets cannot be created. Diagnose: check KMS plugin DaemonSet/pod logs (
kubectl logs -l app=aws-encryption-provider -n kube-system). Check KMS service availability in the cloud console. If KMS is down and API server is operational, existing readable secrets are cached; new creates fail. Restore KMS service or fail over to a backup KMS key. - Secret leaked — immediate response: (1) Identify what was leaked: exact secret name and key, when it was created, who has read access (audit log). (2) Immediately rotate the credential at the source (database, API provider). (3) Update the secret in Kubernetes and restart dependent pods. (4) Revoke the old credential. (5) Investigate how the leak occurred (env var in logs, git commit, overly broad RBAC). (6) File incident report.
- Pod cannot start due to secret not found:
kubectl describe pod <name>will showsecret "x" not found. Check if the secret exists:kubectl get secret <name> -n <ns>. If using ESO: check ExternalSecret sync status. If using CSI driver: check SecretProviderClass and CSI driver pod logs. Create the secret manually if needed for emergency recovery, then fix the root cause. - Secret rotation causing application errors: Identify which pods are failing (check logs/events). If pods are using env vars, they need a restart to pick up new secret values — trigger
kubectl rollout restart deployment/<name>. If Reloader is installed, verify the annotation is set correctly. For CSI-mounted secrets: file content should update automatically; verify the app re-reads the file (not caching at startup).
Best Practices
- Never store secrets in environment variables for sensitive values — use volume mounts. Env vars are exposed in
/proc/self/environ, process listings, core dumps, and accidentally in application logs. Volume-mounted secrets have a smaller exposure surface. For secrets that must be env vars (legacy apps), usesecretKeyRef, notenvFromon the whole secret. - Enable etcd encryption at rest with KMS envelope encryption as the minimum standard. File-based encryption (secretbox/aesgcm) improves over plain-text but the key is on disk alongside the data. KMS envelope encryption keeps the key in a managed service (AWS KMS, GCP KMS, Vault) with independent audit logs and access controls. This is the correct production standard.
- Use an external secret store (Vault, AWS Secrets Manager, GCP Secret Manager) for all production secrets. External stores provide: centralized audit logs, fine-grained access policies per secret, automated rotation, version history, and lease-based TTLs. Kubernetes Secrets should be a sync target (via ESO or CSI driver), not the source of truth.
- Use IRSA/Workload Identity to eliminate static cloud credentials entirely. Any AWS access key, GCP service account key, or Azure client secret stored in Kubernetes is a long-lived credential that can be compromised. With IRSA/Workload Identity, pods authenticate using their Kubernetes ServiceAccount identity federated to cloud IAM — no static credentials stored anywhere.
- Enforce least-privilege RBAC on secrets: get only, specific names, no list/watch. Grant
geton specific secret names viaresourceNames. Never grantlistorwatchon secrets — these return all secret values in the namespace. Audit all RBAC grants on secrets quarterly. - Implement pre-commit secret scanning and CI pipeline scanning. Install detect-secrets or git-secrets as a pre-commit hook in every repository. Add TruffleHog to CI pipelines to scan new commits. Rotate any secret that passes through a CI log or appears in a commit. Make secret scanning a blocking CI gate.
- Automate secret rotation and enforce maximum TTLs. Manual rotation is error-prone and often forgotten. Use cert-manager for TLS, Vault's dynamic secrets for database credentials, and ESO's refreshInterval for external secrets. Enforce maximum TTLs via policy: no API key older than 90 days, no database password older than 30 days. Alert on approaching expiry.
- For GitOps workflows, use Sealed Secrets and back up the controller private key. Plain Kubernetes Secrets cannot be safely stored in Git. Sealed Secrets enables GitOps without compromising security. Always maintain an offline backup of the Sealed Secrets controller private key in a separate secure vault — without it, all encrypted secrets become permanently inaccessible if the controller is lost.