Prefer Bound Projected ServiceAccount Tokens Over Secret
More Info:
Advisory: avoid long-lived ServiceAccount token Secrets; use projected (TokenRequest) tokens with an audience and expiry instead.
Risk Level
Informational
Address
Security
Compliance Standards
- Cloudanix Best Practice
Triage and Remediation
- Remediation
Remediation
Manual Steps
-
List all ServiceAccount token Secrets and map them to identities
- On any machine with kubectl access:
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token -o widekubectl get secrets -A --field-selector type=kubernetes.io/service-account-token -o json \| jq -r '.items[] | [.metadata.namespace, .metadata.name, .metadata.annotations["kubernetes.io/service-account.name"]] | @tsv'
- Record which applications (Deployments/Pods) use each ServiceAccount.
- On any machine with kubectl access:
-
Identify workloads actually mounting these token Secrets
- On any machine with kubectl access:
kubectl get pods -A -o json \| jq -r '.items[] | {ns:.metadata.namespace, pod:.metadata.name, sa:.spec.serviceAccountName, vols:.spec.volumes} |select(.vols!=null) |"\(.ns)\t\(.pod)\t\(.sa)\t" +( [.vols[] | select(.secret!=null) | .secret.secretName] | join(",") )' \| column -t
- Flag Pods where a
secretvolume mounts a ServiceAccount token Secret.
- On any machine with kubectl access:
-
Decide whether each workload actually needs a Kubernetes API token
- For each flagged Pod/Deployment, review its code/configuration to see if it calls the Kubernetes API.
- If it does not need API access, plan to:
- Remove the ServiceAccount reference (fall back to
defaultonly if default SA is locked down), and - Ensure no
secretvolume or env var uses a ServiceAccount token Secret.
- Remove the ServiceAccount reference (fall back to
-
Migrate workloads that do need API access to use projected tokens
- For each workload that truly needs a token, update the Pod spec (Deployment/StatefulSet/Job, etc.) to use a projected ServiceAccount token volume with audience and expiry, instead of a
secretvolume. Example pattern (adapt to your manifest):spec:serviceAccountName: my-savolumes:- name: k8s-sa-tokenprojected:sources:- serviceAccountToken:path: tokenaudience: "https://kubernetes.default.svc.cluster.local"expirationSeconds: 3600containers:- name: appvolumeMounts:- name: k8s-sa-tokenmountPath: /var/run/secrets/tokensreadOnly: true - Apply the change on any machine with kubectl access:
kubectl apply -f <updated-manifest>.yaml
- For each workload that truly needs a token, update the Pod spec (Deployment/StatefulSet/Job, etc.) to use a projected ServiceAccount token volume with audience and expiry, instead of a
-
Clean up legacy ServiceAccount token Secrets once unused
- After updating workloads and confirming they run correctly, check whether a token Secret is still referenced:
kubectl get pods -A -o json \| jq -r '.items[] | {ns:.metadata.namespace, pod:.metadata.name, vols:.spec.volumes} |select(.vols!=null) |.ns as $ns | .pod as $pod |.vols[]? | select(.secret!=null) |"\($ns)\t\($pod)\t\(.secret.secretName)"' \| sort -u
- For any ServiceAccount token Secret no longer referenced by Pods and not needed for external systems, delete it:
kubectl -n <namespace> delete secret <sa-token-secret-name>
- After updating workloads and confirming they run correctly, check whether a token Secret is still referenced:
-
Re-verify and document exceptions
- On any machine with kubectl access, re-run:
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token
- For any remaining ServiceAccount token Secrets, document:
- Which workload or external system uses them,
- Why projected tokens cannot be used yet, and
- A plan or decision to accept the residual risk.
- On any machine with kubectl access, re-run:
Using kubectl
# 1. List all classic ServiceAccount token Secrets
# Run on: any machine with kubectl access
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token
A large number of kubernetes.io/service-account-token Secrets, especially in application namespaces, indicates widespread use of long‑lived SA token Secrets and is a concern.
# 2. Inspect which ServiceAccount a given token Secret belongs to
# Replace NAMESPACE and SECRET_NAME with values from step 1
kubectl get secret -n NAMESPACE SECRET_NAME -o yaml
Concerning signs in the output:
type: kubernetes.io/service-account-token- Mounted in Pods as a volume (see step 3)
- No process in place to rotate or prune these Secrets.
# 3. Find Pods that mount ServiceAccount token Secrets directly
# This surfaces Pods likely using long-lived token Secrets
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{"\n"}{range .spec.volumes[*]}{.name}{" "}{.secret.secretName}{"\n"}{end}{"---\n"}{end}' | grep -v '---'
Lines where secretName matches names from step 1 show Pods mounting SA token Secrets explicitly. These Pods should be reviewed to migrate to projected tokens (TokenRequest) via projected volumes.
# 4. Check if Pods are using default automatic SA token mounting
kubectl get pods -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" sa:"}{.spec.serviceAccountName}{" automount:"}{.spec.automountServiceAccountToken}{"\n"}{end}'
Concerning patterns:
automountServiceAccountTokenis empty (inheritstruefrom the ServiceAccount) or explicitlytruefor Pods in application namespaces, meaning they get a long‑lived token mounted unless the SA is configured otherwise.
# 5. Check ServiceAccount defaults for automatic token mounting
kubectl get sa -A -o jsonpath='{range .items[*]}{.metadata.namespace}{" "}{.metadata.name}{" automount:"}{.automountServiceAccountToken}{"\n"}{end}'
Concerning patterns:
- ServiceAccounts in application namespaces with
automountServiceAccountToken: trueor unset (defaults to true), combined with use in Pods from step 4.
# 6. Identify old / potentially long-lived token Secrets
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token \
-o custom-columns='NAMESPACE:.metadata.namespace,NAME:.metadata.name,SA:.metadata.annotations.kubernetes\.io/service-account\.name,CREATED:.metadata.creationTimestamp' \
| sort
Concerning signs:
- Token Secrets with very old
CREATEDtimestamps that are still referenced by running Pods or external systems.
Use these outputs to decide, per application:
- Which Pods/Workloads still depend on classic SA token Secrets.
- Where you can disable automatic SA token mounting and refactor to projected tokens with explicit audiences and expiries.
Automation
#!/usr/bin/env bash
#
# Report ServiceAccounts that are still using legacy long-lived
# service-account token Secrets instead of projected tokens.
#
# Requirements:
# - Run on any machine with kubectl access and current context set to the target OKE cluster.
# - kubectl must be in PATH.
set -euo pipefail
echo "Collecting legacy ServiceAccount token Secrets (type=kubernetes.io/service-account-token)..."
echo
# 1. List all SA token Secrets and basic metadata
echo "=== All legacy ServiceAccount token Secrets (cluster-wide) ==="
kubectl get secrets -A \
--field-selector type=kubernetes.io/service-account-token \
-o custom-columns=\
NAMESPACE:.metadata.namespace,\
NAME:.metadata.name,\
SA_NAME:.metadata.annotations['kubernetes\.io/service-account\.name'],\
SA_UID:.metadata.annotations['kubernetes\.io/service-account\.uid'],\
CREATED:.metadata.creationTimestamp \
| sed 's/<none>//g'
echo
# 2. Highlight potentially problematic ones:
# - Secrets older than 30 days
# - Secrets whose ServiceAccount no longer exists
THRESHOLD_DAYS=30
THRESHOLD_SECONDS=$(( THRESHOLD_DAYS * 24 * 60 * 60 ))
NOW_EPOCH=$(date -u +%s)
echo "=== Potentially problematic tokens (for review) ==="
echo "# Criteria:"
echo "# - Secret type=kubernetes.io/service-account-token"
echo "# - AND (age > ${THRESHOLD_DAYS} days OR ServiceAccount missing)"
echo
kubectl get secrets -A \
--field-selector type=kubernetes.io/service-account-token \
-o json \
| jq -r --arg now_epoch "${NOW_EPOCH}" --arg thresh "${THRESHOLD_SECONDS}" '
.items[]
| . as $s
| $s.metadata.creationTimestamp as $cts
| ( ($now_epoch|tonumber) - ( ($cts | sub("Z$"; "") + "+00:00") | fromdate )) as $age
| $age as $age_sec
| ($age_sec > ($thresh|tonumber)) as $old
| $s.metadata.annotations["kubernetes.io/service-account.name"] as $sa
| $s.metadata.namespace as $ns
| $s.metadata.name as $name
| if $sa == null then
{
namespace:$ns,
secret:$name,
serviceAccount:"<missing-annotation>",
age_days:($age_sec/86400|floor),
reason:"missing service-account.name annotation"
}
else
{
namespace:$ns,
secret:$name,
serviceAccount:$sa,
age_days:($age_sec/86400|floor),
old:$old
}
end
' \
| while read -r line; do
# For each JSON object, enrich with SA existence info
obj="${line}"
ns=$(jq -r '.namespace' <<< "${obj}")
sa=$(jq -r '.serviceAccount' <<< "${obj}")
if [ "${sa}" != "<missing-annotation>" ]; then
if kubectl get sa "${sa}" -n "${ns}" >/dev/null 2>&1; then
exists="true"
else
exists="false"
fi
else
exists="unknown"
fi
jq --arg exists "${exists}" '. + {serviceAccountExists:$exists}' <<< "${obj}"
done \
| jq -r '
select(
.reason == "missing service-account.name annotation"
or .old == true
or .serviceAccountExists == "false"
)
' \
| jq -r '
[.namespace,
.secret,
.serviceAccount,
.serviceAccountExists,
(if .age_days then (.age_days|tostring) else "N/A" end),
(if .reason then .reason else "" end)
]
| @tsv
' \
| awk 'BEGIN {
OFS="\t";
print "NAMESPACE","SECRET","SERVICEACCOUNT","SA_EXISTS","AGE_DAYS","NOTE"
}
{ print }'
echo
# 3. Per-ServiceAccount summary: count of attached legacy token Secrets
echo "=== ServiceAccount -> legacy token Secret summary ==="
kubectl get secrets -A \
--field-selector type=kubernetes.io/service-account-token \
-o json \
| jq -r '
.items[]
| .metadata as $m
| $m.annotations["kubernetes.io/service-account.name"] as $sa
| select($sa != null)
| [$m.namespace, $sa, $m.name] | @tsv
' \
| awk '{
key=$1 "/" $2
count[key]++
}
END {
OFS="\t";
print "NAMESPACE","SERVICEACCOUNT","LEGACY_TOKEN_SECRET_COUNT"
for (k in count) {
split(k,a,"/")
print a[1],a[2],count[k]
}
}' \
| sort -k1,1 -k2,2
echo
cat <<'EOF'
INTERPRETING OUTPUT (what indicates a problem):
1) "All legacy ServiceAccount token Secrets":
- Any entry here is a long-lived Secret-backed token. These are candidates
for replacement with projected TokenRequest tokens.
2) "Potentially problematic tokens (for review)":
- Rows with AGE_DAYS significantly high (e.g., >30) show long-lived tokens.
- SA_EXISTS = false means the ServiceAccount was deleted but its token Secret remains.
- NOTE mentioning "missing-annotation" suggests an abnormal or legacy Secret.
3) "ServiceAccount -> legacy token Secret summary":
- SERVICEACCOUNTs with LEGACY_TOKEN_SECRET_COUNT >= 1 are still using
Secret-backed tokens. For those workloads, review whether they can be
migrated to use projected serviceAccountToken volumes and short-lived tokens.
This script does NOT modify anything; it only highlights where legacy
ServiceAccount token Secrets are in use so you can review and plan migration.
EOF