ServiceAccount
A ServiceAccount is the identity a workload uses inside a Kubernetes cluster — the answer to “who is this Pod?” Where human users are external entities with no API object, a ServiceAccount is a first-class, namespaced API object (
kind: ServiceAccount, core API group), created and managed like any other resource (Kubernetes — Service Accounts). Every Pod runs as exactly one ServiceAccount — named inspec.serviceAccountName, defaulting to the namespace’sdefaultServiceAccount if unset. The ServiceAccount’s token — a JWT signed by the kube-apiserver — is how the Pod’s processes authenticate back to the API server, and the ServiceAccount’s name is what RBAC bindings reference to grant the Pod permissions. The modern token model (the bound projected token, default since Kubernetes 1.22) makes that credential short-lived, audience-scoped, and tied to the Pod’s lifetime — a large security improvement over the legacy long-lived Secret-stored token it replaced.
Mental Model
A ServiceAccount on its own grants nothing — it is an identity, not a permission. It becomes useful only when (a) a Pod is assigned it and the kubelet projects its token into the Pod, and (b) an RBAC binding attaches permissions to it. The token is the bridge: the Pod presents it on every API call, the apiserver’s authenticator validates it, and the resulting identity is matched against RBAC.
flowchart TD SA["ServiceAccount object<br/>(namespaced, e.g. payments/ci-deployer)"] POD["Pod<br/>spec.serviceAccountName: ci-deployer"] KUBELET["kubelet"] TR["TokenRequest API<br/>(apiserver issues bound JWT)"] PROJ["Projected volume<br/>/var/run/secrets/.../token"] APP["Container process"] APISERVER["kube-apiserver"] AUTHN["Authentication:<br/>verify JWT signature, audience, expiry, Pod binding"] RBAC["RBAC: bindings naming<br/>system:serviceaccount:payments:ci-deployer"] POD -->|references| SA KUBELET -->|"requests token for the Pod's SA"| TR TR -->|"short-lived, audience+Pod-bound JWT"| PROJ KUBELET --> PROJ PROJ -->|"mounted into"| APP APP -->|"Authorization: Bearer <token>"| APISERVER APISERVER --> AUTHN --> RBAC
The diagram shows the modern bound-token path. The insight to extract: the token is not stored at rest anywhere persistent — the kubelet requests a fresh one from the TokenRequest API, projects it into the Pod, auto-rotates it, and it dies with the Pod. Identity (the ServiceAccount object), credential (the token), and authorization (the RBAC binding) are three separate things wired together at Pod-run time.
Mechanical Walk-through
The object and the default ServiceAccount
A ServiceAccount is a tiny namespaced object. Every namespace, on creation, automatically receives a ServiceAccount named default; the control plane re-creates it if deleted. A Pod that does not set spec.serviceAccountName is admitted as default by the ServiceAccount mutating admission plugin (see API Server Request Flow). The default SA has no RBAC bindings out of the box — so a Pod on default can authenticate to the apiserver but is authorized for almost nothing.
Modern tokens — bound projected tokens (default since 1.22)
The current model, BoundServiceAccountTokenVolume, GA since Kubernetes 1.22, works as follows. The kubelet, when starting a Pod, calls the apiserver’s TokenRequest API to mint a JWT for the Pod’s ServiceAccount, then mounts it into the container via a projected volume (serviceAccountToken source), conventionally at /var/run/secrets/kubernetes.io/serviceaccount/token. This token is:
- Short-lived — it carries an
expclaim (default ~1 hour) and the kubelet auto-rotates it, swapping in a fresh token before expiry. - Audience-scoped — the JWT’s
audclaim names the intended recipient(s). A token minted for the apiserver audience is rejected by any service that validates a different audience, so a leaked token cannot be replayed against an unrelated system. - Bound — the JWT embeds the Pod’s UID (and the ServiceAccount’s UID). The apiserver validates the binding: when the Pod is deleted, the token is immediately invalid, even though its
exphas not been reached. A token exfiltrated from a Pod becomes worthless the moment that Pod is gone.
These three properties — expiry, audience, Pod binding — are the entire security improvement: the credential’s blast radius is bounded in time, in scope, and in lifecycle.
Legacy tokens — Secret-stored, long-lived
Before 1.22 the model was different and worse. The control plane auto-created, for every ServiceAccount, a companion Secret of type kubernetes.io/service-account-token holding a non-expiring JWT, and mounted that into Pods. Such tokens never rotate, are not audience-scoped, and remain valid until the Secret is deleted — a leaked one is a permanent credential.
Auto-creation of these token Secrets stopped in Kubernetes 1.24 via the LegacyServiceAccountTokenNoAutoGeneration feature gate — beta and on by default from 1.24, GA in 1.26, and the gate itself removed in 1.27 once the behaviour was locked in (KEP-2799; the ServiceAccount docs note “the feature gate is removed in v1.27, because it was elevated to GA status”). On 1.24+ clusters no token Secret appears when you create a ServiceAccount. If a long-lived token is genuinely needed — typically for an external (out-of-cluster) consumer that cannot use the TokenRequest API — you must manually create a Secret of type kubernetes.io/service-account-token with the kubernetes.io/service-account.name annotation, and the control plane will populate the token into it. In-cluster Pods should never need this; they get bound projected tokens automatically.
Two follow-on features in the same KEP-2799 hardened the migration further, and both are now GA, so they are safe to rely on:
LegacyServiceAccountTokenTracking(alpha 1.26, beta 1.27, GA in 1.28) records alastUseddate on each legacy token Secret and exposes apiserver metrics counting authentications by legacy (Secret-based) versus bound tokens — the concrete scoreboard for driving legacy use to zero.LegacyServiceAccountTokenCleanUp(alpha 1.28, beta 1.29, GA in 1.30) runs a controller that automatically purges auto-generated legacy token Secrets that have been unused for a configurable period (default one year, governed by the--legacy-service-account-token-clean-up-periodapiserver flag). It only touches the old auto-generated Secrets — Secrets you created by hand are left alone.
The 1.24 change removed auto-generation; it did not invalidate manually-created or pre-existing tokens. The 1.30 cleanup controller is what eventually removes the stale auto-generated ones.
Opting out of token mounting
By default a Pod’s ServiceAccount token is projected into every container. A workload that never calls the Kubernetes API has no reason to carry a credential. Set automountServiceAccountToken: false — either on the ServiceAccount (applies to every Pod using it) or on the Pod’s spec (overrides the ServiceAccount setting) — to suppress the projection entirely. This is a baseline hardening step: a compromised Pod with no token cannot talk to the apiserver at all.
The RBAC identity strings
When a Pod’s token authenticates, the apiserver derives a fixed identity from the ServiceAccount:
- Username:
system:serviceaccount:<namespace>:<name>— e.g.system:serviceaccount:payments:ci-deployer. - Groups:
system:serviceaccounts(every ServiceAccount in the cluster) andsystem:serviceaccounts:<namespace>(every ServiceAccount in that namespace), plussystem:authenticated.
These are exactly the subject strings RBAC bindings name. A RoleBinding subject of kind: ServiceAccount, name: ci-deployer, namespace: payments is just a convenient way of writing the system:serviceaccount:payments:ci-deployer username. Binding permissions to the group system:serviceaccounts grants them to every Pod in the cluster — almost always a mistake.
imagePullSecrets on a ServiceAccount
A ServiceAccount can list imagePullSecrets. Any Pod using that ServiceAccount automatically has those registry-credential Secrets applied when pulling images — so private-registry credentials are configured once on the ServiceAccount instead of repeated in every Pod spec.
Configuration / API Surface
# 1. The ServiceAccount: a workload identity, hardened.
apiVersion: v1
kind: ServiceAccount
metadata:
name: ci-deployer
namespace: payments
automountServiceAccountToken: false # default to NO token; Pods opt back in explicitly
imagePullSecrets:
- name: registry-creds # Pods using this SA get these pull credentials
---
# 2. A Pod that explicitly uses the SA and explicitly wants its token.
apiVersion: v1
kind: Pod
metadata:
name: deploy-runner
namespace: payments
spec:
serviceAccountName: ci-deployer # run as this identity (not 'default')
automountServiceAccountToken: true # override the SA's 'false' — this Pod needs API access
containers:
- name: runner
image: registry.internal/ci-runner:1.4
volumeMounts:
- name: api-token
mountPath: /var/run/secrets/tokens
readOnly: true
volumes:
- name: api-token
projected: # the modern bound-token mechanism, made explicit
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3600 # 1h; kubelet auto-rotates before this elapses
audience: https://kubernetes.default.svc # scope: only the apiserver accepts itLine-by-line. automountServiceAccountToken: false on the ServiceAccount flips the cluster-unsafe default — Pods using ci-deployer carry no token unless they ask. The Pod sets serviceAccountName: ci-deployer (running as a dedicated identity, never default) and overrides with automountServiceAccountToken: true because this particular Pod genuinely calls the API. The projected volume with a serviceAccountToken source is the explicit form of what the kubelet does implicitly: expirationSeconds: 3600 requests a one-hour token (the kubelet rotates it automatically), and audience scopes it so the token is useless against anything but the apiserver. The token is bound to this Pod’s UID — delete deploy-runner and the token dies instantly.
# Mint a token by hand (debugging; respects TokenRequest semantics):
kubectl create token ci-deployer -n payments --duration=10m --audience=https://kubernetes.default.svc
# What identity does this SA authenticate as?
kubectl auth can-i --list --as system:serviceaccount:payments:ci-deployer -n paymentsFailure Modes
Workloads silently on default. A Pod with no serviceAccountName runs as default. If anyone ever bound permissions to the default SA (or to system:serviceaccounts), every such Pod inherits them invisibly. Always assign a dedicated, named ServiceAccount per workload.
Binding to the system:serviceaccounts group. A ClusterRoleBinding whose subject is the group system:serviceaccounts grants its role to every Pod in the cluster. The classic over-grant. Audit kubectl get clusterrolebindings -o wide for it.
Legacy long-lived token still in use. A Secret-stored kubernetes.io/service-account-token (manually created, or surviving from a pre-1.24 cluster) is a non-expiring credential. If exfiltrated it is valid forever until the Secret is deleted. Find them: kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token. Migrate consumers to the TokenRequest API.
Token mounted into a workload that never uses it. Most application Pods do not call the Kubernetes API, yet carry a token a compromised process can steal. The token-on-by-default is a standing risk; automountServiceAccountToken: false removes it.
Cross-namespace ServiceAccount confusion. A RoleBinding subject kind: ServiceAccount must specify namespace — the SA may live in a different namespace from the binding. Omitting it silently defaults to the binding’s namespace, granting (or failing to grant) the wrong identity.
Audience mismatch. A token minted for audience A and presented to a service validating audience B is rejected. When integrating a ServiceAccount token with an external system (a cloud OIDC federation, a mesh), the requested audience must match what that system expects — a frequent integration snag.
Alternatives and When to Choose Them
- Bound projected token vs legacy Secret token. Always the bound projected token for in-cluster Pods — short-lived, audience-scoped, Pod-bound. Legacy Secret tokens only for genuine out-of-cluster consumers that cannot reach the
TokenRequestAPI, and even then prefer minting viakubectl create tokenin a refresh loop. - ServiceAccount token vs Workload Identity. A bare ServiceAccount token authenticates to the Kubernetes API. To let a Pod authenticate to a cloud provider’s API (S3, Cloud Storage, a managed database) without static cloud credentials, use Workload Identity (Kubernetes) — GKE Workload Identity, EKS IRSA / EKS Pod Identity, AKS Workload Identity. These federate the Pod’s projected ServiceAccount token with the cloud IAM system: the audience-scoped JWT is exchanged for short-lived cloud credentials. The ServiceAccount remains the Pod’s anchor identity; Workload Identity extends it across the cluster boundary.
- ServiceAccount vs human user identity. ServiceAccounts are for workloads. A human must never share a ServiceAccount token — humans authenticate via OIDC or client certs. A ServiceAccount token in a human’s
~/.kube/configis a misuse. automountServiceAccountToken: falsevs leaving it on. Default it tofalsecluster-wide (or via policy) and have only the Pods that demonstrably call the API opt back in. Defense in depth: most Pods should not carry an API credential at all.
Production Notes
- One ServiceAccount per workload, least-privileged. The blast radius of a compromised Pod is exactly its ServiceAccount’s RBAC. A shared SA across many Deployments couples their security; a dedicated SA per workload keeps the RBAC grant minimal and the audit trail clean.
- Bound tokens survive IdP outages. Because ServiceAccount tokens are signed locally by the apiserver, control loops authenticating with them keep working even when an external OIDC provider used for human auth is down — the structural reason reconciliation does not stop during an identity-provider incident.
TokenRequestfor everything external. CI systems, external monitoring, and out-of-cluster tooling should mint short-lived tokens via theTokenRequestAPI on a refresh schedule rather than holding a static Secret token. The audience-scoping and expiry are free risk reduction.- Watch the legacy-token metrics. Since the
LegacyServiceAccountTokenTrackingfeature (GA 1.28) the apiserver exposes metrics counting authentications by legacy (Secret-based) vs bound tokens, and stamps alastUseddate on each legacy Secret — a concrete migration scoreboard. Drive legacy use to zero, and let theLegacyServiceAccountTokenCleanUpcontroller (GA 1.30) reap the stale auto-generated Secrets once they pass the unused window. - Managed clusters wire ServiceAccounts to cloud IAM by default. On EKS/GKE/AKS, the recommended path for Pods needing cloud access is the provider’s Workload Identity feature, built directly on the projected-token mechanism described here — no static cloud keys in Secrets.
See Also
- Kubernetes RBAC — grants permissions to the
system:serviceaccount:*identities ServiceAccounts produce - Kubernetes Authentication — validates the ServiceAccount JWT; ServiceAccounts are its one “user object” exception
- Workload Identity (Kubernetes) — federates the projected SA token with cloud IAM (GKE WI, EKS IRSA, AKS WI)
- Projected Volume — the volume mechanism the kubelet uses to deliver bound tokens
- Secret — where legacy ServiceAccount tokens were (and manually still can be) stored
- API Server Request Flow — the
ServiceAccountmutating admission plugin assignsdefaultand injects the token volume - kube-apiserver — signs ServiceAccount tokens and serves the
TokenRequestAPI - kubelet — requests and projects (and rotates) the bound token into each Pod
- Pod — every Pod runs as exactly one ServiceAccount
- Service — easily confused by name, but orthogonal: a Service is L4 network addressing for a set of Pods; a ServiceAccount is the identity a Pod authenticates as
- Kubernetes Authorization Modes — the authorizer chain that evaluates a ServiceAccount’s permissions
- Kubernetes MOC — parent MOC (§12 Security)