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-usedlabel 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
defaultjust 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
| Source | What it tells you | Limit to know |
|---|---|---|
ServiceAccount objects (kubectl get sa -A) | Every account, its namespace, creation time, annotations and any secrets references | No usage data at all |
| Pod and workload specs | Which accounts are referenced by spec.serviceAccountName in running Pods and in Deployment, StatefulSet, DaemonSet, Job and CronJob templates | Misses external users of tokens and workloads defined only in GitOps repositories that are not yet applied |
| RoleBindings and ClusterRoleBindings | Which accounts hold rights, and how much | Bindings 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 label | Covers only Secret-based tokens, not TokenRequest or projected tokens |
| API server audit log | Each authenticated request with user.username, verb, resource and source IPs | Only 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.
- Inventory every service account.
Exclude system namespaces (kubectl get serviceaccounts -A -o json \ | jq -r '.items[] | [.metadata.namespace, .metadata.name, .metadata.creationTimestamp] | @tsv' > sa.tsvkube-system,kube-public,kube-node-lease) and provider add-on namespaces from deletion, but keep them in the report: controller accounts inkube-systemare used by the control plane itself. - Find accounts referenced by workloads. Collect
serviceAccountNamefrom running Pods and from controller templates, so that scaled-to-zero Deployments and CronJobs are counted:
Accounts inkubectl 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.tsvsa.tsvthat appear in none of these files are "unreferenced" candidates. Also check your GitOps repositories for manifests not yet applied. - Map rights to each account. List every binding subject of kind ServiceAccount:
For a candidate,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.tsvkubectl auth can-i --list --as=system:serviceaccount:<ns>:<name> -n <ns>shows what it can do. Rank candidates by privilege: any account bound tocluster-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. - Check legacy token Secrets.
The annotationkubectl get secrets -A --field-selector type=kubernetes.io/service-account-token \ -L kubernetes.io/legacy-token-last-used -L kubernetes.io/legacy-token-invalid-sincekubernetes.io/service-account.nameon 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. - Confirm with the audit log. For each candidate, search the window for events where
user.usernameequalssystem:serviceaccount:<ns>:<name>. On a self-managed cluster with a JSON Lines log file:
On EKS, run the equivalent filter in CloudWatch Logs Insights against thejq -r 'select(.user.username | startswith("system:serviceaccount:")) | .user.username' audit.log* | sort | uniq -c > sa-activity.txtkube-apiserver-auditstreams 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.extracarriesauthentication.kubernetes.io/pod-nameandauthentication.kubernetes.io/credential-id(JTI=...), which help you tell which Pod or token made a call. - Neutralise (reversible). Export, then remove the rights and stop new token mounts:
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 forkubectl 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-deleteForbiddenerrors from that username in the audit log. - Delete (destructive). When the wait passes cleanly and the owner agrees:
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.kubectl delete sa <name> -n <ns> kubectl delete secret <legacy-token-secret> -n <ns>
Reading the signals together
| Referenced by workloads | Audit events in window | Legacy token recently used | Verdict |
|---|---|---|---|
| No | None | No (or no Secret) | Unused: neutralise then delete |
| No | Yes | Any | Used from outside the cluster or by a manual kubectl create token: find the consumer, do not delete |
| No | None | Yes | Audit coverage gap or log retention too short: fix logging and re-check |
| Yes (scaled to zero or CronJob) | None | No | Possibly seasonal: confirm schedule with the owner before any change |
| Yes | None | No | Mounted but never calls the API: keep the account, set automountServiceAccountToken: false and remove its bindings |
| Yes | Yes | Any | In 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
Forbiddenor 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
| Situation | Fix |
|---|---|
A job fails with Forbidden after step 6 | kubectl apply -f backup/rb-...yaml, record the real consumer, then trim the role instead |
| A deleted account is needed | Re-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 deletion | The workload identity binding still references the account name; recreate the account or update the binding |
| Legacy token rejected as invalidated | The 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 short | Extend 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 flagged | Leave control plane and add-on accounts to their owners; exclude the namespace from deletion |
Hardening afterwards
- Disable automount by default: set
automountServiceAccountToken: falseon 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
| Metric | Direction |
|---|---|
| Service accounts with no audit events in the window | Falling to zero outside documented exceptions |
| Bindings naming non-existent service accounts | Zero |
| Legacy service account token Secrets | Falling to zero |
| Unused accounts holding cluster-scoped rights | Zero |
| Share of Pods with token automount disabled where not needed | Rising |
Framework mapping
| Framework | Reference | Relevance |
|---|---|---|
| NIST SP 800-53 Rev 5 | AC-2(3) Disable Accounts; AC-6 Least Privilege | Remove inactive accounts and their unneeded rights |
| NIST SP 800-53 Rev 5 | AU-2 Event Logging | API server audit logging is the usage evidence |
| NIST CSF 2.0 | PR.AA-01; PR.AA-05 | Service identities managed; permissions reviewed |
| CIS Controls v8.1 | 5.3 Disable Dormant Accounts | Same intent applied to service accounts |
| MITRE ATT&CK | T1552.007 Container API; T1078.001 Default Accounts | Token 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
serviceAccountNamereferences, 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:serviceaccountusername. - Export YAML, remove bindings, label and wait one full job cycle.
- Delete accounts and their cloud workload identity bindings together.
- Set
automountServiceAccountToken: falseondefaultand on accounts that never call the API.
Standards and references
- Kubernetes: Managing Service Accounts
- Kubernetes: Service Accounts
- Kubernetes: Auditing
- AWS: Send control plane logs to CloudWatch Logs
- NIST: SP 800-53 Rev. 5 Security and Privacy Controls
- NIST: Cybersecurity Framework 2.0
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