Minimize Access To The Service Account Token Creation
More Info:
The ability to create the token sub-resource of service accounts lets subjects mint credentials for other identities. Restrict token creation access to trusted administrators only.
Risk Level
High
Address
Security
Compliance Standards
- CIS EKS
Triage and Remediation
- Remediation
Remediation
Manual Steps
-
Identify all roles with
serviceaccounts/tokencreate permission
Run on any machine with kubectl access:kubectl get clusterroles,roles -A -o json \| jq -r '.items[]| select(.rules != null)| select(any(.rules[];(.resources // []) | index("serviceaccounts/token")and ((.verbs // []) | index("create"))))| .kind + "/" + .metadata.name + " (ns: " + (.metadata.namespace // "cluster-scope") + ")"'Save the output as your initial inventory of roles to review.
-
List all bindings that grant those roles
Using the role names from step 1, run (replaceROLE_NAMEandROLE_KIND):kubectl get clusterrolebindings -o json \| jq -r '.items[]| select(.roleRef.kind=="ROLE_KIND" and .roleRef.name=="ROLE_NAME")| "ClusterRoleBinding/" + .metadata.name + " -> " + (.subjects // [] | map(.kind + ":" + (.namespace // "") + ":" + .name) | join(","))'kubectl get rolebindings -A -o json \| jq -r '.items[]| select(.roleRef.kind=="ROLE_KIND" and .roleRef.name=="ROLE_NAME")| "RoleBinding/" + .metadata.namespace + "/" + .metadata.name + " -> " + (.subjects // [] | map(.kind + ":" + (.namespace // "") + ":" + .name) | join(","))'This shows which users, groups, and service accounts can create tokens.
-
Decide which subjects truly need token-creation capability
For each subject from step 2, determine:- Is this a trusted admin identity (e.g., platform SRE group)?
- Is token minting required for its function (e.g., CI system creating short-lived tokens)?
- Could it instead use normal service account mounting or pre-created secrets?
Mark subjects as “required”, “over-privileged”, or “unknown; investigate owner”.
-
Restrict or remove access for over-privileged subjects
For any binding you decide should not have this right:- If the role is used only for token creation, delete the binding:
kubectl delete clusterrolebinding BINDING_NAMEkubectl delete rolebinding -n NAMESPACE BINDING_NAME
- If the role has other needed permissions, clone and edit it to remove the
serviceaccounts/tokencreaterule, then re-bind:kubectl get clusterrole ORIGINAL_ROLE -o yaml > /tmp/original-role.yaml# Edit /tmp/original-role.yaml: remove any rule containing resources: ["serviceaccounts/token"] and verbs: ["create"...]kubectl apply -f /tmp/original-role.yaml
Ensure only trusted admin identities remain bound to roles that still include this permission.
- If the role is used only for token creation, delete the binding:
-
Optionally create a dedicated, tightly scoped admin role for token creation
Run if you need explicit admin-only token creation capability:cat << 'EOF' | kubectl apply -f -apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRolemetadata:name: admin-serviceaccount-token-creatorrules:- apiGroups: [""]resources: ["serviceaccounts/token"]verbs: ["create"]EOFBind this only to a small, trusted admin group:
cat << 'EOF' | kubectl apply -f -apiVersion: rbac.authorization.k8s.io/v1kind: ClusterRoleBindingmetadata:name: admin-serviceaccount-token-creator-bindingroleRef:apiGroup: rbac.authorization.k8s.iokind: ClusterRolename: admin-serviceaccount-token-creatorsubjects:- kind: Groupname: YOUR-ADMIN-GROUP-NAMEEOF -
Re-verify effective access
Re-run the discovery from step 1 and 2 to confirm only intended admin identities retaincreateonserviceaccounts/token:kubectl get clusterroles,roles -A -o json | jq -r '...same jq as in step 1...'kubectl get clusterrolebindings,rolebindings -A -o yaml | grep -A5 -E 'serviceaccounts/token|ROLE_NAME_YOU_EXPECT'Confirm no non-admin or unintended subjects appear in these results.
Using kubectl
# 1. List all roles/clusterroles that can create service account tokens
# Run on: any machine with kubectl access
kubectl get clusterrole,role -A -o json \
| jq -r '
.items[]
| select(
.rules[]
| select(
(.resources[]? == "serviceaccounts/token")
and (.verbs[]? | IN("create";"*"))
)
)
| [.kind, .metadata.name, (.metadata.namespace // "-")]
| @tsv
' | column -t
How to read this:
- Output columns:
KIND NAME NAMESPACE - Any
ClusterRoleorRolelisted here allows creating service account tokens. ClusterRoleentries are cluster-wide; impact is larger than namespace-scopedRole.- A problem exists if:
- The role name is clearly meant for applications or CI/CD (not admin/ops), or
- You see very generic roles (e.g.
edit,admin,*) granting this, or - There are many such roles without a clear administrative purpose.
# 2. Inspect the detailed rules for each suspicious role/clusterrole
# Replace <KIND> and <NAME> from the previous command
# Run on: any machine with kubectl access
kubectl get <KIND> <NAME> -o yaml
Focus on:
resources: ["serviceaccounts/token"]verbscontainingcreateor*
A problem exists if non-admin or broadly used roles (bound to many subjects) contain this rule.
# 3. Find what is bound to each role/clusterrole with token create
# Run on: any machine with kubectl access
# (a) For clusterroles
for cr in $(kubectl get clusterrole -o json \
| jq -r '.items[]
| select(
.rules[]
| select(
(.resources[]? == "serviceaccounts/token")
and (.verbs[]? | IN("create";"*"))
)
)
| .metadata.name'); do
echo "=== ClusterRole: $cr ==="
kubectl get clusterrolebinding -o json \
| jq -r --arg CR "$cr" '
.items[]
| select(.roleRef.kind=="ClusterRole" and .roleRef.name==$CR)
| .metadata.name as $rb
| .subjects[]?
| [$rb, .kind, .name, (.namespace // "-")]
| @tsv
' | column -t || echo " (no bindings)"
echo
done
# (b) For namespaced roles
for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
for r in $(kubectl get role -n "$ns" -o json \
| jq -r '.items[]
| select(
.rules[]
| select(
(.resources[]? == "serviceaccounts/token")
and (.verbs[]? | IN("create";"*"))
)
)
| .metadata.name'); do
echo "=== Role: $r (namespace: $ns) ==="
kubectl get rolebinding -n "$ns" -o json \
| jq -r --arg R "$r" '
.items[]
| select(.roleRef.kind=="Role" and .roleRef.name==$R)
| .metadata.name as $rb
| .subjects[]?
| [$rb, .kind, .name, (.namespace // "-")]
| @tsv
' | column -t || echo " (no bindings)"
echo
done
done
How to read this:
- Columns:
ROLEBINDING-NAME SUBJECT-KIND SUBJECT-NAME SUBJECT-NAMESPACE - Problematic patterns:
UserorGroupsubjects representing general developer groups, CI groups, or broad SSO groups.ServiceAccountsubjects for application workloads, not admin/ops.- Many subjects bound to the same token-creating role (widespread privilege).
# 4. Quick high-level summary by subject for review
# Run on: any machine with kubectl access
kubectl get clusterrolebinding,rolebinding -A -o json \
| jq -r '
.items[]
| .metadata.namespace as $ns
| .metadata.name as $rb
| .roleRef.kind as $rk
| .roleRef.name as $rn
| .subjects[]? as $s
| [$ns, $rb, $rk, $rn, $s.kind, $s.name, ($s.namespace // "-")]
| @tsv
' | column -t
Cross-check:
- For each binding where the referenced role/clusterrole was found in step 1, ask:
- Is this subject a trusted administrator identity?
- Does this subject truly need to mint tokens for other service accounts?
If the answer is “no” or unclear, that binding is likely a problem and should be considered for restriction or removal during manual remediation.
Automation
#!/usr/bin/env bash
set -euo pipefail
# This script must run on any machine with kubectl configured for the target cluster.
# It requires:
# - access to cluster-wide RBAC (cluster-admin or equivalent)
# - kubectl in PATH
echo "=== Detecting RBAC permissions to create serviceaccount token subresources ==="
echo
# Helper: pretty header
header() {
printf '\n===== %s =====\n' "$1"
}
# 1) List ALL ClusterRoles and Roles that can create serviceaccount tokens
header "1) Roles/ClusterRoles that can create serviceaccounts/token"
# We look for:
# - apiGroups: [""]
# - resources: ["serviceaccounts/token"] (preferred new-style)
# - OR resources: ["serviceaccounts"] with resourceNames including "token"
# - verbs including "create"
#
# This prints a line per matching rule: KIND;NAMESPACE;NAME;VERBS;RESOURCES;RESOURCENAMES
kubectl get clusterroles -o json \
| jq -r '
.items[]
| . as $cr
| .rules[]
| select(
(
# explicit subresource
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts/token") != null and
(.verbs // []) | index("create") != null
) or (
# serviceaccounts with specific resourceNames potentially including token
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts") != null and
(.verbs // []) | index("create") != null and
(.resourceNames // []) != null and
((.resourceNames[] | ascii_downcase) | contains("token"))
)
)
| "ClusterRole;;\($cr.metadata.name);\(.verbs|join(","));\(.resources|join(","));\((.resourceNames // [])|join(","))"
' \
| sort || true
kubectl get roles --all-namespaces -o json \
| jq -r '
.items[]
| . as $r
| .rules[]
| select(
(
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts/token") != null and
(.verbs // []) | index("create") != null
) or (
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts") != null and
(.verbs // []) | index("create") != null and
(.resourceNames // []) != null and
((.resourceNames[] | ascii_downcase) | contains("token"))
)
)
| "Role;\($r.metadata.namespace);\($r.metadata.name);\(.verbs|join(","));\(.resources|join(","));\((.resourceNames // [])|join(","))"
' \
| sort || true
echo
echo "Columns: KIND;NAMESPACE;ROLE_NAME;VERBS;RESOURCES;RESOURCENAMES"
echo "Any non-empty output above represents RBAC roles that can create service account tokens."
echo
# 2) For each such role, show which subjects actually receive that permission via bindings
header "2) Bindings (who actually gets those permissions)"
# Build a temp list of all roles with token-create permission
TMP_ROLES="$(mktemp)"
TMP_BINDS="$(mktemp)"
trap 'rm -f "$TMP_ROLES" "$TMP_BINDS"' EXIT
{
kubectl get clusterroles -o json \
| jq -r '
.items[]
| . as $cr
| select(
any(.rules[]?;
(
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts/token") != null and
(.verbs // []) | index("create") != null
) or (
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts") != null and
(.verbs // []) | index("create") != null and
(.resourceNames // []) != null and
((.resourceNames[] | ascii_downcase) | contains("token"))
)
)
)
| "ClusterRole;;\($cr.metadata.name)"
'
kubectl get roles --all-namespaces -o json \
| jq -r '
.items[]
| . as $r
| select(
any(.rules[]?;
(
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts/token") != null and
(.verbs // []) | index("create") != null
) or (
(.apiGroups // []) | index("") != null and
(.resources // []) | index("serviceaccounts") != null and
(.verbs // []) | index("create") != null and
(.resourceNames // []) != null and
((.resourceNames[] | ascii_downcase) | contains("token"))
)
)
)
| "Role;\($r.metadata.namespace);\($r.metadata.name)"
'
} | sort > "$TMP_ROLES"
# Collect all RoleBindings and ClusterRoleBindings
kubectl get rolebindings --all-namespaces -o json > "$TMP_BINDS.role"
kubectl get clusterrolebindings -o json > "$TMP_BINDS.clusterrole"
echo "KIND;BINDING_NAMESPACE;BINDING_NAME;ROLE_KIND;ROLE_NAMESPACE;ROLE_NAME;SUBJECT_KIND;SUBJECT_NAMESPACE;SUBJECT_NAME"
# ClusterRoleBindings
jq -r --argfile roles "$TMP_ROLES" '
def has_role($kind; $ns; $name):
($roles[] | select(.[0]==$kind and .[1]==$ns and .[2]==$name)) | length > 0;
.items[]
| . as $b
| .roleRef as $r
| select(
has_role($r.kind; ""; $r.name)
)
| (.subjects // [{kind:"User",name:"<no-subjects>"}])[]
| [
"ClusterRoleBinding",
"", # binding namespace
$b.metadata.name,
$r.kind,
"", # role namespace
$r.name,
.kind,
(.namespace // ""),
.name
] | @csv
' "$TMP_BINDS.clusterrole" | sed 's/^"\|"$//g;s/","/;/g'
# RoleBindings
jq -r --argfile roles "$TMP_ROLES" '
def has_role($kind; $ns; $name):
($roles[] | select(.[0]==$kind and .[1]==$ns and .[2]==$name)) | length > 0;
.items[]
| . as $b
| .roleRef as $r
| select(
has_role($r.kind; $b.metadata.namespace; $r.name)
)
| (.subjects // [{kind:"User",name:"<no-subjects>"}])[]
| [
"RoleBinding",
$b.metadata.namespace,
$b.metadata.name,
$r.kind,
$b.metadata.namespace,
$r.name,
.kind,
(.namespace // ""),
.name
] | @csv
' "$TMP_BINDS.role" | sed 's/^"\|"$//g;s/","/;/g'
echo
cat <<'EOF'
How to interpret the binding output:
- Each line is:
KIND;BINDING_NAMESPACE;BINDING_NAME;ROLE_KIND;ROLE_NAMESPACE;ROLE_NAME;SUBJECT_KIND;SUBJECT_NAMESPACE;SUBJECT_NAME
- Every line represents a subject that can create service account tokens.
What indicates a potential problem:
1) Any subject that is not a tightly controlled administrator identity, for example:
- SUBJECT_KIND=User or Group representing broad or external users.
- SUBJECT_KIND=ServiceAccount used by application workloads.
- Wildcard or broad groups (e.g., system:authenticated, system:serviceaccounts).
2) Any ClusterRoleBinding that grants this power cluster-wide to non-admin subjects.
3) Roles/ClusterRoles where:
- VERBS contains "*" or includes "create" alongside very broad permissions.
- RESOURCES includes "*" or many unrelated resources.
Next steps (manual review required):
- For any suspect line, decide whether that subject truly needs to mint service account tokens.
- If not required, update or delete the corresponding Role/ClusterRole or Binding to remove create on serviceaccounts/token.
- Re-run this script after changes to confirm only trusted administrator identities remain.
EOF