Service accounts & tokens

Disable automount; scope and bind narrowly.

Advanced12 min · lesson 8 of 24

Every pod runs as a ServiceAccount, and if you do not name one it runs as the namespace default — with its token mounted into the container at a well-known path. Most pods never call the Kubernetes API, so for them that token is pure attack surface: a credential an intruder who lands in the container can read and replay against the API server. The first move is to stop mounting it where it is not used.

How a pod authenticates to the API server
authenticated request allow deny kubelet TokenRequest projection issues + rotates the token Pod workload container SA token · JWT short-lived · audience-bound kube-apiserver request pipeline · two gates 1 · Authenticate verify signature + expiry → system:serviceaccount:ns:name 2 · Authorize · RBAC check (Cluster)RoleBinding for that ServiceAccount ✓ etcd / resource ALLOWED · request proceeds ✕ 403 Forbidden DENIED · no binding grants it Identity = the signed token · Permissions = RBAC. A leaked long-lived token is dangerous, so tokens are short-lived + audience-scoped.
authenticated request allowed denied token issued / rotated
Identity comes from the signed ServiceAccount token; permissions come from RBAC — so tokens are short-lived and audience-scoped.
serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata: { name: payments-api, namespace: payments }
automountServiceAccountToken: false # default off for the account
---
apiVersion: v1
kind: Pod
metadata: { name: payments-api, namespace: payments }
spec:
serviceAccountName: payments-api
automountServiceAccountToken: false # and/or opt out per-pod (wins)
containers: [{ name: app, image: registry.internal/payments-api:1.4.2 }]

How tokens work now

The old model minted a Secret holding a JWT that never expired — steal it once and it worked forever. Modern Kubernetes uses bound service-account tokens: the kubelet requests a short-lived, audience-scoped token via the TokenRequest API and projects it into the pod, rotating it before expiry. Combined with --service-account-lookup=true on the API server, deleting the ServiceAccount actually invalidates its tokens, which the legacy model could not do.

terminal
# an on-demand, expiring token (not a stored forever-secret)
$ kubectl create token payments-api -n payments --duration=1h
eyJhbGciOiJSUzI1NiIsImtpZCI6... # valid 1h, audience = the API server
# the projected token a pod actually gets, with audience + expiry, kubelet-rotated

Least privilege by binding

Where a workload does need API access, give it a dedicated ServiceAccount bound to a narrow Role — never the default account, never a ClusterRoleBinding to something broad. When a task hands you several accounts and asks for the least-privileged one, read the roles bound to each: an account with no RoleBinding at all has zero granted permissions, which is frequently the correct answer.

terminal
# which SAs in this namespace have bindings, and to what?
$ kubectl -n omni get rolebindings,clusterrolebindings -o json | jq -r '
.items[] | .roleRef.name as $r | .subjects[]?
| select(.kind=="ServiceAccount") | "\(.name) -> \($r)"'
fe -> edit
frontend -> view
# frontend-default appears nowhere -> it has the fewest privileges
The default SA is not harmless
A container running as the namespace default ServiceAccount with automounting on hands an intruder a live API credential for nothing. Set automountServiceAccountToken: false on the default ServiceAccount in every namespace, and let the few workloads that need the API opt back in with a dedicated, scoped account.