Skip to main content

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​

Manual Steps
  1. List all legacy ServiceAccount token Secrets

    • Run on: any machine with kubectl access.
    kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token -o wide
    • Identify which namespaces and ServiceAccounts are still using auto-created token Secrets.
  2. Map each token Secret to its ServiceAccount and workloads

    • For a specific Secret (replace SECRET and NAMESPACE):
    kubectl get secret SECRET -n NAMESPACE -o yaml
    • Note the .metadata.annotations["kubernetes.io/service-account.name"].
    • Check which pods use that ServiceAccount:
    kubectl get pods -n NAMESPACE -o wide --field-selector spec.serviceAccountName=SERVICEACCOUNT
  3. Identify direct Secret mounting or out-of-cluster use

    • Check if the token Secret is mounted into pods:
    kubectl get pods -n NAMESPACE -o yaml | grep -C5 "name: SECRET"
    • Review pod and application configuration (manifests, CI/CD, external systems) to see if the Secret name or its token value is referenced for out-of-cluster access (automation scripts, external services).
  4. Decide if each use can be migrated to projected ServiceAccount tokens

    • For in-cluster workloads: plan to replace secret volume mounts with a projected serviceAccountToken volume (TokenRequest) that sets audience and expirationSeconds.
    • For out-of-cluster callers: consider alternatives such as Workload Identity (GKE recommended), or a dedicated authentication method, instead of reusing long-lived cluster ServiceAccount tokens.
  5. Update manifests to stop relying on long-lived token Secrets

    • Edit workloads that mount ServiceAccount token Secrets and replace with a projected token volume. Example pattern (to adapt into the pod spec that used the Secret):
    spec:
    serviceAccountName: SERVICEACCOUNT
    volumes:
    - name: sa-token
    projected:
    sources:
    - serviceAccountToken:
    path: token
    audience: YOUR-AUDIENCE
    expirationSeconds: 3600
    containers:
    - name: APP
    volumeMounts:
    - name: sa-token
    mountPath: /var/run/secrets/tokens
    readOnly: true
    • Apply the updated manifest from a machine with kubectl access:
    kubectl apply -f UPDATED-MANIFEST.yaml
  6. Verify no unnecessary ServiceAccount token Secrets remain in active use

    • After pods are updated and restarted, confirm they are not mounting the legacy token Secrets (inspect a sample pod):
    kubectl get pod PODNAME -n NAMESPACE -o yaml | grep -C5 "secret"
    • Re-list legacy token Secrets and decide whether each can be deleted (only after confirming nothing depends on it):
    kubectl get secrets -N NAMESPACE --field-selector type=kubernetes.io/service-account-token
    • Optionally, delete an unused token Secret:
    kubectl delete secret SECRET -n NAMESPACE
Using kubectl
# 1. List all long-lived ServiceAccount token Secrets
# Run on: any machine with kubectl access
kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token

Interpretation:

  • Large numbers of kubernetes.io/service-account-token Secrets, especially in application namespaces, indicate workloads are likely using legacy long-lived tokens instead of projected tokens.
  • Pay special attention to non-system namespaces; kube-system/gke-* may contain some platform-managed tokens.
# 2. For a specific token Secret, inspect details
# Replace NAMESPACE and SECRET_NAME with values from the list above
kubectl -n NAMESPACE get secret SECRET_NAME -o yaml

Interpretation:

  • metadata.annotations["kubernetes.io/service-account.name"] shows which ServiceAccount the token belongs to.
  • Absence of any reference to kubernetes.io/service-account.token-expiration means it is a legacy-style long-lived token.
  • Any external system documented as consuming this Secret directly (for example via CI/CD or off-cluster scripts) is relying on a long-lived token.
# 3. List ServiceAccounts in an application namespace
# Replace NAMESPACE with your app namespace (not kube-system)
kubectl -n NAMESPACE get serviceaccounts
# 4. For a given ServiceAccount, see its Secrets and automount setting
kubectl -n NAMESPACE get serviceaccount SERVICEACCOUNT_NAME -o yaml

Interpretation:

  • Under secrets:, any entries that correspond to kubernetes.io/service-account-token Secrets from step 1 are legacy tokens.
  • Check automountServiceAccountToken:
    • If true or unset, pods using this ServiceAccount will auto-mount a token (legacy on older clusters / manifests).
    • If you intend to use projected tokens only, this typically should be set to false and explicit projected volumes used in Pods.
# 5. Find Pods that mount ServiceAccount token Secrets directly
kubectl -n NAMESPACE get pods -o jsonpath='{range .items[*]}{.metadata.name}{"\t"}{.spec.volumes[*].secret.secretName}{"\n"}{end}' | grep -v '^\s*$'

Interpretation:

  • Any Pod that lists a Secret name that you identified in step 1 (type kubernetes.io/service-account-token) is explicitly mounting a long-lived token Secret.
  • This indicates the workload is not using a projected ServiceAccountToken volume.
# 6. Inspect a Pod spec for how it gets its token
kubectl -n NAMESPACE get pod POD_NAME -o yaml

Interpretation:

  • Look under spec.volumes:
    • secret: name: <token-secret> → using a long-lived ServiceAccount token Secret.
    • projected: sources: - serviceAccountToken: with audience and expirationSeconds → using a bound/projected token (desired).
  • Under spec.serviceAccountName and spec.automountServiceAccountToken:
    • A Pod relying only on the default automounted token and not using projected.serviceAccountToken is a candidate for migration.
# 7. (Optional) Check if a ServiceAccount token Secret is actually referenced by any Pod
# Replace NAMESPACE with the namespace you are reviewing
for s in $(kubectl -n NAMESPACE get secrets --field-selector type=kubernetes.io/service-account-token -o jsonpath='{.items[*].metadata.name}'); do
echo "Secret: $s"
kubectl -n NAMESPACE get pods -o jsonpath="{range .items[*]}{.metadata.name}{': '}{range .spec.volumes[*]}{.secret.secretName}{','}{end}{'\n'}{end}" | grep "$s" || echo " (no pods referencing this secret)"
echo
done

Interpretation:

  • Secrets not referenced by any Pods may be unused legacy tokens that can potentially be phased out.
  • Secrets actively referenced by Pods indicate those workloads must be carefully migrated to use projected ServiceAccount tokens with audience and expiry.
Automation
#!/usr/bin/env bash
# Report long-lived ServiceAccount token Secrets and their usage (GKE)
# Run on: any machine with kubectl access and appropriate RBAC

set -euo pipefail

echo "=== ServiceAccount token Secrets (type=kubernetes.io/service-account-token) ==="
kubectl get secrets -A \
--field-selector type=kubernetes.io/service-account-token \
-o json \
| jq -r '
.items[]
| {
ns: .metadata.namespace,
name: .metadata.name,
sa: .metadata.annotations["kubernetes.io/service-account.name"],
created: .metadata.creationTimestamp,
# Approximate size of the JWT; very large tokens may indicate legacy/default tokens
token_bytes: (.data.token // "" | @base64d | length),
# In GKE, bound tokens typically have an "kubernetes.io/service-account.uid" annotation;
# presence alone does NOT guarantee projected/bound usage, but absence is a red flag.
sa_uid: .metadata.annotations["kubernetes.io/service-account.uid"]
}
| @tsv
' \
| awk 'BEGIN {
OFS="\t";
print "NAMESPACE","SECRET","SERVICEACCOUNT","CREATED","TOKEN_BYTES","SA_UID_PRESENT"
}
{
sa_uid = ($6 == "" ? "no" : "yes");
print $1,$2,$3,$4,$5,sa_uid
}'

echo
echo "=== Pods that mount ServiceAccount token Secrets directly as volumes ==="
# Pods that explicitly mount a SA token Secret instead of using the default
# projected token volume are higher risk.
kubectl get pods -A -o json \
| jq -r '
.items[]
| {
ns: .metadata.namespace,
pod: .metadata.name,
sa: .spec.serviceAccountName,
vols: .spec.volumes
}
| select(.vols != null)
| .vols[]
| select(.secret != null)
| {
ns: input.ns,
pod: input.pod,
sa: input.sa,
vol_name: .name,
secret_name: .secret.secretName
}
| @tsv
' 2>/dev/null \
| awk 'BEGIN {
OFS="\t";
print "NAMESPACE","POD","SERVICEACCOUNT","VOLUME_NAME","SECRET_NAME"
}
{ print }'

echo
echo "=== Pods using default ServiceAccount token projection ==="
# These pods rely on the projected token volume mechanism, which is preferred.
kubectl get pods -A -o json \
| jq -r '
.items[]
| {
ns: .metadata.namespace,
pod: .metadata.name,
sa: .spec.serviceAccountName,
automount: (.spec.automountServiceAccountToken // "default"),
projected: (
.spec.volumes // []
| map(select(.projected != null))
| map(
.projected.sources // []
| map(select(.serviceAccountToken != null))
| length
)
| add
)
}
| select(.projected > 0 or .automount != false)
| @tsv
' \
| awk 'BEGIN {
OFS="\t";
print "NAMESPACE","POD","SERVICEACCOUNT","AUTOMOUNT_SA_TOKEN","NUM_PROJECTED_SA_TOKEN_SOURCES"
}
{ print }'

cat <<'EOF'

INTERPRETING THE OUTPUT (WHAT INDICATES A PROBLEM):

1) "ServiceAccount token Secrets" table:
- Any row is a potential concern: long-lived Secrets of type
"kubernetes.io/service-account-token" exist.
- "SA_UID_PRESENT = no" is a stronger red flag (likely older-style token Secret).
- Very old "CREATED" timestamps suggest long-lived tokens that should be rotated away.
- Large "TOKEN_BYTES" values are normal for JWTs; focus on existence and age rather than size.

2) "Pods that mount ServiceAccount token Secrets directly as volumes":
- Any row here is a concern: the pod is explicitly mounting a ServiceAccount token Secret,
which tends to create long-lived, broadly scoped credentials.

3) "Pods using default ServiceAccount token projection":
- Preferred pattern is: AUTONMOUNT_SA_TOKEN = true or "default",
and NUM_PROJECTED_SA_TOKEN_SOURCES > 0, with application logic using TokenRequest
(projected) tokens with audience+expiry.
- This report only shows presence of projected token sources; you still need to review
app code/manifests to confirm that TokenRequest-style tokens are used correctly.

This script does not change any resources. Use it periodically to identify
namespaces/ServiceAccounts/Pods that still rely on long-lived ServiceAccount token Secrets
and prioritize them for manual refactoring to projected, short-lived tokens.
EOF