Prefer Secrets Files Over Secrets As Environment Variables
More Info:
Kubernetes supports mounting secrets as data volumes or as environment variables. Minimize the use of environment variable secrets.
Risk Level
High
Address
Security
Compliance Standards
- APRA CPS 234 (Australia)
- BSI C5 (Germany)
- Brazil LGPD
- CCPA / CPRA (California)
- CIS Critical Security Controls v8
- CIS GKE
- CMMC 2.0
- CSA Cloud Controls Matrix v4
- DPDPA
- Digital Operational Resilience Act (EU)
- Essential 8
- ISO/IEC 27017
- ISO/IEC 27018
- ISO/IEC 27701
- KSA PDPL
- MAS Technology Risk Management (Singapore)
- MITRE ATT&CK (Cloud)
- NIST SP 800-171
- NYDFS 23 NYCRR 500
- SWIFT Customer Security Controls Framework
- Sarbanes-Oxley IT General Controls
- UK NCSC Cyber Assessment Framework
Triage and Remediation
- Remediation
Remediation
Manual Steps
-
Identify workloads using secrets as environment variables
- Run on any machine with kubectl access:
kubectl get all --all-namespaces -o jsonpath='{range .items[?(@..secretKeyRef)]} {.kind} {.metadata.namespace} {.metadata.name} {"\n"}{end}'
- Save the output and decide which Deployments/DaemonSets/StatefulSets/Pods/Jobs/CronJobs you will change.
- Run on any machine with kubectl access:
-
Inspect how the secret is used in each workload
- For each item from step 1, describe it and review the env section:
kubectl -n NAMESPACE get KIND NAME -o yaml
- Confirm the application reads the secret from environment variables and determine if it can instead read from a file path.
- For each item from step 1, describe it and review the env section:
-
Update application code/configuration to read secrets from files
- Modify application code or configuration outside the cluster so it reads secrets from a file path (for example
/var/run/secrets/<app>/<key>) instead of an environment variable. - Build and push the updated image or adjust configuration so that the application expects the secret at the chosen file path.
- Modify application code or configuration outside the cluster so it reads secrets from a file path (for example
-
Modify the workload manifest to mount the secret as a volume
- Edit the manifest on any machine with kubectl access (either via file + apply, or
kubectl edit):- Remove
enventries that usesecretKeyRef. - Add a
volumesentry at the pod spec level and avolumeMountsentry for the container. Example pattern:
spec:volumes:- name: my-secret-volumesecret:secretName: my-secretcontainers:- name: appimage: your-registry/your-image:tagvolumeMounts:- name: my-secret-volumemountPath: /var/run/secrets/my-appreadOnly: true - Remove
- Apply updated manifests if editing locally:
kubectl apply -f UPDATED_MANIFEST.yaml
- Edit the manifest on any machine with kubectl access (either via file + apply, or
-
Validate application behavior and remove remaining env-secret usage
- Confirm the updated pods are running and healthy:
kubectl -n NAMESPACE get podskubectl -n NAMESPACE logs POD_NAME
- If the application works with file-based secrets, ensure no remaining
enventries withsecretKeyRefexist in that workload:kubectl -n NAMESPACE get KIND NAME -o json | jq '..|objects|select(has("secretKeyRef"))'
- Confirm the updated pods are running and healthy:
-
Verification (cluster-wide)
- Re-run the audit command from any machine with kubectl access:
output=$(kubectl get all --all-namespaces -o jsonpath='{range .items[?(@..secretKeyRef)]} {.kind} {.metadata.name} {"\n"}{end}')if [ -z "$output" ]; then echo "NO_ENV_SECRET_REFERENCES"; else echo "ENV_SECRET_REFERENCES_FOUND"; fi
- Repeat steps 2–5 until
NO_ENV_SECRET_REFERENCESis printed or only the explicitly accepted exceptions remain.
- Re-run the audit command from any machine with kubectl access:
Using kubectl
On any machine with kubectl access:
- Identify workloads using
env/envFromsecret references
kubectl get all --all-namespaces -o jsonpath='{range .items[?(@..secretKeyRef)]} {.kind} {.metadata.namespace} {.metadata.name} {"\n"}{end}'
- For each affected Pod controller (Deployment/StatefulSet/DaemonSet/Job/CronJob), fetch the manifest, edit it locally, and re-apply.
Example for a Deployment:
# Export current manifest
kubectl get deployment <deployment-name> -n <namespace> -o yaml > deployment-with-env-secrets.yaml
Edit deployment-with-env-secrets.yaml:
- Remove
enventries that usesecretKeyRef, or entireenvFromentries that reference a Secret. - Add a
volumesourced from the Secret. - Mount that volume into the container at a path your application can read.
Example transformation:
Before:
spec:
template:
spec:
containers:
- name: app
image: your-image
env:
- name: DB_PASSWORD
valueFrom:
secretKeyRef:
name: db-secret
key: password
After:
spec:
template:
spec:
containers:
- name: app
image: your-image
volumeMounts:
- name: db-secret-vol
mountPath: /etc/secrets/db
readOnly: true
env: [] # or remove the specific secret env entries
volumes:
- name: db-secret-vol
secret:
secretName: db-secret
Your application must be updated to read /etc/secrets/db/password instead of the environment variable.
Apply the updated manifest:
kubectl apply -f deployment-with-env-secrets.yaml
Repeat this export/edit/apply pattern for each affected controller kind:
kubectl get statefulset <name> -n <namespace> -o yaml > sts-with-env-secrets.yaml
kubectl apply -f sts-with-env-secrets.yaml
kubectl get daemonset <name> -n <namespace> -o yaml > ds-with-env-secrets.yaml
kubectl apply -f ds-with-env-secrets.yaml
kubectl get job <name> -n <namespace> -o yaml > job-with-env-secrets.yaml
kubectl apply -f job-with-env-secrets.yaml
kubectl get cronjob <name> -n <namespace> -o yaml > cj-with-env-secrets.yaml
kubectl apply -f cj-with-env-secrets.yaml
- Verification
After updating all workloads and redeploying the application code to read mounted files, confirm there are no remaining secretKeyRef usages:
output=$(kubectl get all --all-namespaces -o jsonpath='{range .items[?(@..secretKeyRef)]} {.kind} {.metadata.name} {"\n"}{end}')
if [ -z "$output" ]; then echo "NO_ENV_SECRET_REFERENCES"; else echo "ENV_SECRET_REFERENCES_FOUND"; fi
Automation
#!/usr/bin/env bash
#
# Automation: Identify workloads using Secret environment variables and
# generate file-based Secret mounts plus updated manifests.
#
# NOTE: This script CANNOT safely and generically rewrite application code
# or update all manifests automatically, because that requires
# application-specific changes (code/config reading from files instead
# of env vars) and deployment pipeline coordination.
#
# It instead:
# - Detects all env-var Secret references
# - Generates suggested pod spec patches that mount the same Secrets
# as files under /var/run/secrets/<secret-name>
# - Optionally (opt-in) applies those patches to add volume mounts
# while leaving env vars intact (for a migration period)
#
# You must then:
# - Update application code/config to read from files
# - Remove the env-var Secret usage from the manifests
#
# REQUIREMENTS:
# - Run on: any machine with kubectl access to the cluster
# - Tools: bash, kubectl, jq, yq (go-yq v4 style: yq eval)
#
# USAGE:
# chmod +x automate_secret_env_to_files.sh
# ./automate_secret_env_to_files.sh
#
# Environment flags:
# DRY_RUN=true # (default) only print patches; do not apply
# DRY_RUN=false # apply patches that add volumes/volumeMounts
#
set -euo pipefail
DRY_RUN="${DRY_RUN:-true}"
if ! command -v kubectl >/dev/null 2>&1; then
echo "ERROR: kubectl not found in PATH" >&2
exit 1
fi
if ! command -v jq >/dev/null 2>&1; then
echo "ERROR: jq not found in PATH" >&2
exit 1
fi
if ! command -v yq >/dev/null 2>&1; then
echo "ERROR: yq not found in PATH (expected go-yq v4: 'yq eval')" >&2
exit 1
fi
echo "=== Detecting Kubernetes resources that use Secret env vars ==="
ENV_SECRET_JSON=$(kubectl get all --all-namespaces -o json | jq '
.items[]
| select(..|has("secretKeyRef")? )
| {kind, metadata: {name: .metadata.name, namespace: .metadata.namespace}}
')
if [ -z "$ENV_SECRET_JSON" ]; then
echo "NO_ENV_SECRET_REFERENCES (no resources with env secretKeyRef found)."
exit 0
fi
echo "$ENV_SECRET_JSON" | jq -r '"- \(.kind) \(.metadata.namespace)/\(.metadata.name)"'
cat <<'EOF'
INFO:
- The remediation requires changing applications to read Secrets from files
instead of environment variables.
- This script cannot change application code; it only helps add file-based
Secret mounts to the affected workloads.
EOF
# Work on workload controllers, not individual Pods:
# Supported kinds: Deployment, StatefulSet, DaemonSet, Job, CronJob, ReplicaSet, ReplicationController
SUPPORTED_KINDS='["Deployment","StatefulSet","DaemonSet","Job","CronJob","ReplicaSet","ReplicationController"]'
# Get unique (namespace, kind, name) triplets that reference secretKeyRef
echo "=== Building unique list of controllers to process ==="
CONTROLLERS_JSON=$(kubectl get all --all-namespaces -o json | jq -r --argjson kinds "$SUPPORTED_KINDS" '
.items[]
| select(..|has("secretKeyRef")? )
| select(.kind as $k | $k | IN($kinds[]) )
| {"kind": .kind, "name": .metadata.name, "namespace": .metadata.namespace}
| @base64
')
if [ -z "$CONTROLLERS_JSON" ]; then
echo "No supported controller types with env-based Secrets were found."
exit 0
fi
TMPDIR=$(mktemp -d)
trap 'rm -rf "$TMPDIR"' EXIT
process_controller() {
local enc="$1"
local obj
obj=$(echo "$enc" | base64 --decode)
local kind ns name
kind=$(echo "$obj" | jq -r '.kind')
ns=$(echo "$obj" | jq -r '.namespace')
name=$(echo "$obj" | jq -r '.name')
echo "=== Processing $kind $ns/$name ==="
local yaml
if ! yaml=$(kubectl -n "$ns" get "$kind" "$name" -o yaml 2>/dev/null); then
echo "WARN: Failed to fetch $kind $ns/$name, skipping." >&2
return
fi
local podspec_path
case "$kind" in
Deployment|StatefulSet|DaemonSet|ReplicaSet|ReplicationController)
podspec_path=".spec.template.spec"
;;
Job)
podspec_path=".spec.template.spec"
;;
CronJob)
podspec_path=".spec.jobTemplate.spec.template.spec"
;;
*)
echo "WARN: Unsupported kind $kind, skipping." >&2
return
;;
esac
# Identify all secretKeyRef.secretName used in env or envFrom
local secrets_json
secrets_json=$(echo "$yaml" | yq eval -o=json "$podspec_path" - | jq '
def walk(f):
. as $in
| if type == "object" then
reduce keys[] as $key
( {}; . + { ($key): ($in[$key] | walk(f)) } ) | f
elif type == "array" then
map( walk(f) ) | f
else
f
end;
walk(.) as $node
| if ($node|type) == "object" and ($node.env? or $node.envFrom?) then
[
($node.env[]?|select(.valueFrom.secretKeyRef?).valueFrom.secretKeyRef.secretName),
($node.envFrom[]?|select(.secretRef?).secretRef.name)
]
else
empty
end
')
if [ -z "$secrets_json" ]; then
echo " No secretKeyRef found in pod spec (likely only in status), skipping."
return
fi
local unique_secrets
unique_secrets=$(echo "$secrets_json" | jq -r '.[]' | sed '/^null$/d' | sort -u)
if [ -z "$unique_secrets" ]; then
echo " No env-based Secret names resolved, skipping."
return
fi
echo " Env-based Secrets referenced:"
echo "$unique_secrets" | sed 's/^/ - /'
# Build a patch that:
# - adds volumes for each Secret (if not already present)
# - adds volumeMounts for each container (readOnly, mountPath /var/run/secrets/<secret>)
# Existing volumes/volumeMounts are preserved.
local patch_file="$TMPDIR/${ns}-${kind}-${name}-patch.yaml"
# Start with current spec for manipulation in yq
echo "$yaml" > "$TMPDIR/${ns}-${kind}-${name}-orig.yaml"
# Build yq expression to update podSpec
local yq_expr=""
# Ensure .spec.template.spec.volumes or jobTemplate variant exists and add any missing Secret volumes
echo "$unique_secrets" | while read -r secret; do
[ -n "$secret" ] || continue
# sanitize for volume name
vol_name="secret-${secret//./-}"
yq_expr+="
$podspec_path.volumes |= ( . // [] ) |
$podspec_path.volumes |=
( if map(.name) | index(\"$vol_name\") == null then
. + [{\"name\":\"$vol_name\",\"secret\":{\"secretName\":\"$secret\"}}]
else
.
end ) |
"
done
# For each container & initContainer: ensure volumeMounts and add mount for each secret
yq_expr+="
($podspec_path.containers[]?, $podspec_path.initContainers[]?) as \$c ireduce (.; .) |
$podspec_path.containers |= ( . // [] ) |
$podspec_path.initContainers |= ( . // [] ) |
$podspec_path.containers |=
( map(
. as \$ct |
.volumeMounts |= ( . // [] )
| .volumeMounts |= (
. +
["
first_secret=1
echo "$unique_secrets" | while read -r secret; do
[ -n "$secret" ] || continue
vol_name="secret-${secret//./-}"
mount_path="/var/run/secrets/${secret}"
if [ $first_secret -eq 0 ]; then
yq_expr+=","
fi
first_secret=0
yq_expr+="
select( [ .[].name ] | index(\"$vol_name\") == null ) |
{\"name\":\"$vol_name\",\"mountPath\":\"$mount_path\",\"readOnly\":true}
"
done
yq_expr+="]
)
) ) |
$podspec_path.initContainers |=
( map(
. as \$ct |
.volumeMounts |= ( . // [] )
| .volumeMounts |= (
. +
["
first_secret=1
echo "$unique_secrets" | while read -r secret; do
[ -n "$secret" ] || continue
vol_name="secret-${secret//./-}"
mount_path="/var/run/secrets/${secret}"
if [ $first_secret -eq 0 ]; then
yq_expr+=","
fi
first_secret=0
yq_expr+="
select( [ .[].name ] | index(\"$vol_name\") == null ) |
{\"name\":\"$vol_name\",\"mountPath\":\"$mount_path\",\"readOnly\":true}
"
done
yq_expr+="]
)
) )
"
# Apply yq expression and build final patch
yq eval "$yq_expr" "$TMPDIR/${ns}-${kind}-${name}-orig.yaml" > "$patch_file"
echo " Generated patch with Secret file mounts at: $patch_file"
if [ "$DRY_RUN" = "true" ]; then
echo " DRY_RUN=true: showing diff (no changes applied):"
diff -u "$TMPDIR/${ns}-${kind}-${name}-orig.yaml" "$patch_file" || true
else
echo " Applying patch with kubectl apply -f:"
kubectl apply -f "$patch_file"
fi
}
echo "=== Generating patches to add file-based secret mounts ==="
while read -r enc; do
[ -n "$enc" ] || continue
process_controller "$enc"
done <<< "$CONTROLLERS_JSON"
echo
echo "=== Verification: re-running audit for env-based Secret usage ==="
output=$(kubectl get all --all-namespaces -o jsonpath='{range .items[?(@..secretKeyRef)]} {.kind} {.metadata.name} {"\n"}{end}')
if [ -z "$output" ]; then
echo "NO_ENV_SECRET_REFERENCES"
else
echo "ENV_SECRET_REFERENCES_FOUND (env-based secrets still present in these resources):"
echo "$output"
echo
echo "NOTE:"
echo " - The benchmark remediation ultimately requires removing env-based Secret usage."
echo " - After you update application code to read Secrets from files and clean up"
echo " env vars in your manifests, re-run this script to confirm."
fi