Join our Newsletter — 33% off our NHI Course
Home› Guides› How to find unused Kubernetes service accounts
Guide Cloud & Workload Identity

How to find unused Kubernetes service accounts

← All guides
By NHI Mgmt Group, edited by Lalit Choda Updated 11 November 2026 12 min read
On this page

Kubernetes has no "last used" field on a ServiceAccount, so unused service accounts and the RBAC bindings attached to them pile up quietly in every long-lived cluster. Each one is an identity that can still be requested a token, mounted into a new Pod or used by an external system holding an old Secret-based token. This runbook shows how to find unused service accounts by combining four sources the cluster already has: the ServiceAccount objects, the workloads that reference them, the RBAC bindings that grant them rights, and the API server audit log that shows whether they actually authenticated. It then covers how to neutralise, delete and roll back safely. It is for platform and cloud security engineers running self-managed or managed clusters (EKS, GKE, AKS). For rotating tokens of service accounts you keep, see How to rotate a Kubernetes service account token and detect old token use.

This is an independent guide from NHI Mgmt Group, based on the Kubernetes project's public documentation as of November 2026. It is not sponsored or reviewed by the Kubernetes project or any cloud provider. Behaviour differs between Kubernetes versions, so confirm against the documentation for your version.

Key takeaways

  • Usage lives in the audit log, not the object. A service account authenticates as system:serviceaccount:<namespace>:<name>; absence of that username in the audit log over your window is the strongest evidence it is unused.
  • Not referenced is not the same as unused. Workloads scaled to zero, CronJobs and external systems holding tokens all use service accounts that no running Pod references.
  • Legacy token Secrets have their own signal. On current Kubernetes versions (the cleaner is stable from v1.30), auto-generated legacy tokens carry the kubernetes.io/legacy-token-last-used label and are invalidated after a year of non-use by default.
  • Remove rights before identities. Export and delete the RoleBindings and ClusterRoleBindings first, watch for failures, then delete the ServiceAccount.
  • Leave the default account alone. Deleting default just makes the control plane recreate it; instead stop it being mounted and make sure it holds no rights.

When you need this

  • Periodic access review: clusters that have run for years accumulate accounts from removed Helm releases, proofs of concept and departed teams. NIST SP 800-53 AC-2(3) Disable Accounts and CIS Controls v8.1 Safeguard 5.3 both expect dormant accounts to be disabled.
  • Before tightening RBAC: removing unused identities first shrinks the set of bindings you must review. See the Kubernetes RBAC Guide.
  • After a cluster compromise or a leaked kubeconfig: attackers who reach a node or Pod look for service account tokens with useful rights (MITRE ATT&CK T1552.007 Container API). Fewer privileged accounts means fewer useful tokens. See the Kubernetes Cluster Compromise Playbook.
  • Before a workload identity migration: knowing which accounts are live tells you which cloud IAM bindings need moving.

Before you start

SourceWhat it tells youLimit to know
ServiceAccount objects (kubectl get sa -A)Every account, its namespace, creation time, annotations and any secrets referencesNo usage data at all
Pod and workload specsWhich accounts are referenced by spec.serviceAccountName in running Pods and in Deployment, StatefulSet, DaemonSet, Job and CronJob templatesMisses external users of tokens and workloads defined only in GitOps repositories that are not yet applied
RoleBindings and ClusterRoleBindingsWhich accounts hold rights, and how muchBindings can name accounts that no longer exist
Legacy token Secrets (type: kubernetes.io/service-account-token)kubernetes.io/legacy-token-last-used date and kubernetes.io/legacy-token-invalid-since labelCovers only Secret-based tokens, not TokenRequest or projected tokens
API server audit logEach authenticated request with user.username, verb, resource and source IPsOnly as good as the policy and retention; requires --audit-policy-file and a backend

Audit logging must be on. On self-managed clusters the kube-apiserver needs --audit-policy-file and --audit-log-path (or a webhook backend); without a policy file no events are logged. A policy of a single rule at level: Metadata is enough for this task because you need the username, not request bodies. On EKS, enable the audit control plane log type, which is delivered to the CloudWatch log group /aws/eks/<cluster>/cluster:

aws eks update-cluster-config --region <region> --name <cluster> \
  --logging '{"clusterLogging":[{"types":["audit"],"enabled":true}]}'

On GKE and AKS, use the provider's Kubernetes audit log export into your logging platform. Whichever platform, check the retention covers your chosen window (90 days is a common start; use longer for quarterly or annual jobs).

Permissions: discovery needs cluster-wide get and list on serviceaccounts, pods, workload controllers, secrets metadata and RBAC objects, plus read access to the log store. Run it from a read-only identity and keep delete rights in a separate, approved change.

Step-by-step

Steps 1 to 5 are read-only. Step 6 removes rights and is reversible. Step 7 deletes accounts.

  1. Inventory every service account.
    kubectl get serviceaccounts -A -o json \
      | jq -r '.items[] | [.metadata.namespace, .metadata.name, .metadata.creationTimestamp] | @tsv' > sa.tsv
    Exclude system namespaces (kube-system, kube-public, kube-node-lease) and provider add-on namespaces from deletion, but keep them in the report: controller accounts in kube-system are used by the control plane itself.
  2. Find accounts referenced by workloads. Collect serviceAccountName from running Pods and from controller templates, so that scaled-to-zero Deployments and CronJobs are counted:
    kubectl get pods -A -o json \
      | jq -r '.items[] | [.metadata.namespace, (.spec.serviceAccountName // "default")] | @tsv' > used-pods.tsv
    kubectl get deploy,sts,ds,job -A -o json \
      | jq -r '.items[] | [.metadata.namespace, (.spec.template.spec.serviceAccountName // "default")] | @tsv' > used-controllers.tsv
    kubectl get cronjob -A -o json \
      | jq -r '.items[] | [.metadata.namespace, (.spec.jobTemplate.spec.template.spec.serviceAccountName // "default")] | @tsv' > used-cron.tsv
    Accounts in sa.tsv that appear in none of these files are "unreferenced" candidates. Also check your GitOps repositories for manifests not yet applied.
  3. Map rights to each account. List every binding subject of kind ServiceAccount:
    kubectl get rolebindings,clusterrolebindings -A -o json \
      | jq -r '.items[] | .metadata.namespace as $ns | .kind as $k | .metadata.name as $b
          | .roleRef.name as $r | (.subjects // [])[] | select(.kind=="ServiceAccount")
          | [$k, ($ns // "-"), $b, $r, .namespace, .name] | @tsv' > sa-bindings.tsv
    For a candidate, kubectl auth can-i --list --as=system:serviceaccount:<ns>:<name> -n <ns> shows what it can do. Rank candidates by privilege: any account bound to cluster-admin, to roles with * verbs, or with rights on secrets, pods/exec or RBAC objects goes to the top. Bindings that name an account which no longer exists are orphans and can be removed in step 6.
  4. Check legacy token Secrets.
    kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token \
      -L kubernetes.io/legacy-token-last-used -L kubernetes.io/legacy-token-invalid-since
    The annotation kubernetes.io/service-account.name on each Secret names its account. A Secret with an old or missing last-used date and no mounting Pod is a strong unused signal. A recent last-used date means something outside the cluster may still hold that token, so the account is in use even if no Pod references it.
  5. Confirm with the audit log. For each candidate, search the window for events where user.username equals system:serviceaccount:<ns>:<name>. On a self-managed cluster with a JSON Lines log file:
    jq -r 'select(.user.username | startswith("system:serviceaccount:"))
      | .user.username' audit.log* | sort | uniq -c > sa-activity.txt
    On EKS, run the equivalent filter in CloudWatch Logs Insights against the kube-apiserver-audit streams of /aws/eks/<cluster>/cluster. Accounts with zero events in the window, no references in step 2 and no recent legacy token use are your "unused" list. Where tokens are Pod-bound, user.extra carries authentication.kubernetes.io/pod-name and authentication.kubernetes.io/credential-id (JTI=...), which help you tell which Pod or token made a call.
  6. Neutralise (reversible). Export, then remove the rights and stop new token mounts:
    kubectl get sa <name> -n <ns> -o yaml > backup/sa-<ns>-<name>.yaml
    kubectl get rolebinding <binding> -n <ns> -o yaml > backup/rb-<ns>-<binding>.yaml
    kubectl delete rolebinding <binding> -n <ns>
    kubectl label sa <name> -n <ns> review/status=pending-delete
    For bindings shared with other subjects, edit the subject list rather than deleting the binding. Wait through one full cycle of the slowest job (a week, a month or a quarter) and watch for Forbidden errors from that username in the audit log.
  7. Delete (destructive). When the wait passes cleanly and the owner agrees:
    kubectl delete sa <name> -n <ns>
    kubectl delete secret <legacy-token-secret> -n <ns>
    Deleting the account invalidates tokens bound to it, because the API server checks the bound object still exists. Remove any matching cloud workload identity binding (for example the IAM role trust or Google service account binding that names this Kubernetes account) at the same time.

Reading the signals together

Referenced by workloadsAudit events in windowLegacy token recently usedVerdict
NoNoneNo (or no Secret)Unused: neutralise then delete
NoYesAnyUsed from outside the cluster or by a manual kubectl create token: find the consumer, do not delete
NoNoneYesAudit coverage gap or log retention too short: fix logging and re-check
Yes (scaled to zero or CronJob)NoneNoPossibly seasonal: confirm schedule with the owner before any change
YesNoneNoMounted but never calls the API: keep the account, set automountServiceAccountToken: false and remove its bindings
YesYesAnyIn use: review rights instead

The fifth row is common and worth acting on: many applications never talk to the Kubernetes API, yet get a token mounted because automountServiceAccountToken was never set to false in the Pod or ServiceAccount spec.

The default service account

Kubernetes creates a ServiceAccount named default in every namespace, assigns it to any Pod that does not name another account, and recreates it if deleted. Do not try to clean it up. Instead check that no binding grants default any rights (a binding to system:serviceaccounts or system:serviceaccounts:<ns> groups also reaches it) and set automountServiceAccountToken: false on it where workloads do not need API access.

Verify it worked

  • Inventory: re-running step 1 shows the deleted accounts gone, and step 3 shows no bindings naming them or other missing accounts.
  • Tokens: step 4 returns no legacy token Secrets for deleted accounts.
  • No breakage: the audit log shows no Forbidden or authentication failures for the removed usernames, and the owning teams confirm their jobs ran through a full cycle.
  • Cloud side: the matching cloud IAM bindings for removed accounts are also gone.

Rollback and common errors

SituationFix
A job fails with Forbidden after step 6kubectl apply -f backup/rb-...yaml, record the real consumer, then trim the role instead
A deleted account is neededRe-apply the saved ServiceAccount and binding YAML; the new object has a new UID, so external holders of old tokens must obtain new ones
Cloud API calls fail after deletionThe workload identity binding still references the account name; recreate the account or update the binding
Legacy token rejected as invalidatedThe cleaner added kubernetes.io/legacy-token-invalid-since; the Kubernetes documentation notes removing that label temporarily re-allows the token, but plan a move to TokenRequest tokens
Account looks unused but audit retention is shortExtend retention or wait for a longer window; do not delete on less evidence than your slowest job cycle
A controller account in kube-system was flaggedLeave control plane and add-on accounts to their owners; exclude the namespace from deletion

Hardening afterwards

  • Disable automount by default: set automountServiceAccountToken: false on ServiceAccounts and opt in per Pod that needs the API.
  • Stop creating Secret-based tokens: the Kubernetes project recommends against long-lived bearer tokens stored in Secrets; use TokenRequest or projected tokens, which expire and rotate.
  • Require owner labels: enforce owner and purpose labels on ServiceAccounts through admission policy so step 6 is a lookup, not an investigation.
  • Schedule the job: run steps 1 to 5 monthly and feed the result into your NHI inventory. See NHI Dormancy Detection.
  • Keep legacy cleanup on: leave the legacy token cleaner at its default period (--legacy-service-account-token-clean-up-period, one year) or shorten it on self-managed control planes.

Small cluster or large fleet

One cluster with a few namespaces can run the commands above and review by hand. A fleet of many clusters should centralise audit logs, run the queries per cluster on a schedule, and compare against GitOps source so accounts defined in code but missing from the cluster, and the reverse, are both reported.

Metrics

MetricDirection
Service accounts with no audit events in the windowFalling to zero outside documented exceptions
Bindings naming non-existent service accountsZero
Legacy service account token SecretsFalling to zero
Unused accounts holding cluster-scoped rightsZero
Share of Pods with token automount disabled where not neededRising

Framework mapping

FrameworkReferenceRelevance
NIST SP 800-53 Rev 5AC-2(3) Disable Accounts; AC-6 Least PrivilegeRemove inactive accounts and their unneeded rights
NIST SP 800-53 Rev 5AU-2 Event LoggingAPI server audit logging is the usage evidence
NIST CSF 2.0PR.AA-01; PR.AA-05Service identities managed; permissions reviewed
CIS Controls v8.15.3 Disable Dormant AccountsSame intent applied to service accounts
MITRE ATT&CKT1552.007 Container API; T1078.001 Default AccountsToken harvesting from Pods; abuse of default identities

Practitioner checklist

  • Confirm API server audit logging is on with enough retention for your slowest job.
  • Inventory all ServiceAccounts and all serviceAccountName references, including controller templates and CronJobs.
  • Map every RoleBinding and ClusterRoleBinding subject of kind ServiceAccount, and remove orphans.
  • List legacy token Secrets with their last-used and invalid-since labels.
  • Search the audit log for each candidate's system:serviceaccount username.
  • Export YAML, remove bindings, label and wait one full job cycle.
  • Delete accounts and their cloud workload identity bindings together.
  • Set automountServiceAccountToken: false on default and on accounts that never call the API.

Standards and references

Related NHI Mgmt Group resources: Kubernetes Workload Identity Checklist · How to rotate a Kubernetes service account token and detect old token use · Kubernetes RBAC Guide · NHI Dormancy Detection

Free weekly newsletter

Subscribe to the NHI & AI Identity Journal

The latest on NHI and Agentic AI security – articles, research, breaches, news and events every week.

Bonus 33% off our NHI Course when you subscribe.

NHIMG Editorial Note
Researched and written by the NHI Mgmt Group editorial team using AI tools, under the editorial direction of Lalit Choda. Facts are drawn from the sources listed on this page and checked by our automated quality process. Last updated 11 November 2026. Spotted an error? Use our contact page.
NHI Mgmt Group, the independent authority on Non-Human Identity, IAM and Agentic AI security. nhimg.org