Skip to main content

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

Manual Steps
  1. Identify all roles with serviceaccounts/token create 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.

  2. List all bindings that grant those roles
    Using the role names from step 1, run (replace ROLE_NAME and ROLE_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.

  3. 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”.
  4. 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_NAME
      kubectl delete rolebinding -n NAMESPACE BINDING_NAME
    • If the role has other needed permissions, clone and edit it to remove the serviceaccounts/token create rule, 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.

  5. 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/v1
    kind: ClusterRole
    metadata:
    name: admin-serviceaccount-token-creator
    rules:
    - apiGroups: [""]
    resources: ["serviceaccounts/token"]
    verbs: ["create"]
    EOF

    Bind this only to a small, trusted admin group:

    cat << 'EOF' | kubectl apply -f -
    apiVersion: rbac.authorization.k8s.io/v1
    kind: ClusterRoleBinding
    metadata:
    name: admin-serviceaccount-token-creator-binding
    roleRef:
    apiGroup: rbac.authorization.k8s.io
    kind: ClusterRole
    name: admin-serviceaccount-token-creator
    subjects:
    - kind: Group
    name: YOUR-ADMIN-GROUP-NAME
    EOF
  6. Re-verify effective access
    Re-run the discovery from step 1 and 2 to confirm only intended admin identities retain create on serviceaccounts/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 ClusterRole or Role listed here allows creating service account tokens.
  • ClusterRole entries are cluster-wide; impact is larger than namespace-scoped Role.
  • 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"]
  • verbs containing create or *

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:
    • User or Group subjects representing general developer groups, CI groups, or broad SSO groups.
    • ServiceAccount subjects 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