Service accounts & tokens
Disable automount; scope and bind narrowly.
An attacker who pops a shell in one of your pods reaches for one file first: /var/run/secrets/kubernetes.io/serviceaccount/token. That's the pod's service-account credential, and Kubernetes drops a copy into almost every container by default, whether the app ever talks to the API server or not. Most apps never do. For them the token is free attack surface: a live cluster credential sitting on disk, waiting to be read and replayed against the API server. Cat the file, point kubectl or curl at the control plane, and you're now acting as that identity. No password prompt, no alarm. And because it's an ordinary bearer token, nothing about the replay looks odd to the control plane. The request arrives authenticated, RBAC says yes or no, and the audit log just shows that service account doing what that service account is allowed to do.
Every pod runs as a ServiceAccount. Name one and the pod uses it; say nothing and it inherits the namespace default account. Either way the token lands at that well-known path unless you disable automount. A token for an app that never calls the API is unused convenience and extra credentials on disk. Turning automount off for those pods cuts blast radius: a compromised process then has no cluster identity from that file, so a lot of pop-a-pod-then-own-the-namespace chains stop there.
Turn the mount off, then prove it
The automountServiceAccountToken field sits in two places, and that redundancy is deliberate. Set it on the ServiceAccount and it becomes the default for every pod that uses that account. Set it on the pod and the pod's value wins, which lets you keep a blanket 'off' at the account level and still carve out the one workload that truly needs a token. The two-place design also survives churn. Someone redeploys the workload from a fresh manifest that forgot the account-level setting? The pod still inherits 'off' from the ServiceAccount. Someone needs a token for one debug pod? They flip it on for that pod without punching a hole for everything else on the account. Applying the YAML isn't proof, though. A manifest describes what you asked for; you want to watch the running container come up with nothing to steal. So apply it, then exec in and look at the path yourself.
apiVersion: v1kind: ServiceAccountmetadata: { name: payments-api, namespace: payments }automountServiceAccountToken: false # default off for this account---apiVersion: v1kind: Podmetadata: { name: payments-api, namespace: payments }spec:serviceAccountName: payments-apiautomountServiceAccountToken: false # per-pod opt-out wins over the SA settingcontainers:- { name: app, image: registry.internal/payments-api:1.4.2 }
# apply it, then confirm the container has no token to steal$ kubectl apply -f serviceaccount.yamlserviceaccount/payments-api createdpod/payments-api created$ kubectl exec payments-api -n payments -- ls /var/run/secrets/kubernetes.io/serviceaccountls: /var/run/secrets/kubernetes.io/serviceaccount: No such file or directorycommand terminated with exit code 1
Modern tokens expire; the old ones never did
The legacy model minted a Secret holding a JWT (JSON Web Token, a signed blob that carries its own claims) and gave it no expiry. Steal it once and it worked forever. Deleting the account didn't even help, because the old default let the API server keep honoring the token without re-checking that the identity behind it still existed. Modern Kubernetes uses bound tokens instead. The kubelet asks the TokenRequest API for a short-lived token scoped to a specific audience (the recipient the token is allowed to talk to), projects it into the pod as a file, and rotates it well before it expires. The token is also tied to the pod, so when the pod dies the token dies with it. Leave --service-account-lookup at its default of true (the CIS Benchmark, from the Center for Internet Security, asks you to set it explicitly so it can't silently drift off) and the API server checks on every request that the account still exists, so deleting the account really does kill its tokens. There's a grace window worth knowing about: if the account is only pending deletion because a finalizer is holding it, the token keeps working until roughly sixty seconds past the deletion timestamp, then authentication starts failing. A token exfiltrated from a crashed or evicted pod is already dead weight by the time an attacker gets around to using it.
# mint an on-demand token and read its claims: short life, scoped audience$ kubectl create token payments-api -n payments --duration=1h \| cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, exp}'{"aud": ["https://kubernetes.default.svc"],"exp": 1784217600}# and confirm the API server still re-checks accounts (CIS wants this set explicitly)$ kubectl -n kube-system get pod kube-apiserver-cp1 -o yaml \| grep -- '--service-account-lookup'- --service-account-lookup=true
One account, one job
The token authenticates. RBAC (Role-Based Access Control) authorizes. Mixing those up is how a "protected" token still has cluster-admin behind it. For a workload that genuinely needs the API, give it its own ServiceAccount bound to a narrow Role. Don't reuse the namespace default for it. Don't reach for a ClusterRoleBinding when a namespaced Role will do, because a ClusterRoleBinding grants that access in every namespace at once. Auditing a namespace you didn't build? Read the bindings before you trust a thing. An account with no binding at all holds zero permissions, and that's often the safest account in the room. One more habit: start an account at no permissions and add back only what actually breaks. It's far easier to grant a missing verb than to notice, months later, that an account you copied from an example can list every Secret in the cluster.
# which accounts 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)"'api-worker -> editfrontend -> view# every other account in omni, including default, is absent here, so it carries no permissions
# give the account exactly one job: read configmaps in its own namespace$ kubectl create role cfg-reader -n payments \--verb=get,list --resource=configmapsrole.rbac.authorization.k8s.io/cfg-reader created$ kubectl create rolebinding payments-api-cfg -n payments \--role=cfg-reader --serviceaccount=payments:payments-apirolebinding.rbac.authorization.k8s.io/payments-api-cfg created# verify with the real authorizer, not by eyeballing YAML$ kubectl auth can-i list configmaps -n payments \--as=system:serviceaccount:payments:payments-apiyes$ kubectl auth can-i list secrets -n payments \--as=system:serviceaccount:payments:payments-apino
The default account is where this bites
The account that quietly hurts the most is the namespace default. Every pod that doesn't name a ServiceAccount lands on it, and with automount on, each of those pods is handing an intruder a live API credential for nothing. So flip it. Set automountServiceAccountToken: false on the default account in every namespace, then let the handful of workloads that really call the API opt back in with their own scoped account. Do the sweep once, then verify the whole cluster in a single read instead of trusting that the loop did what you meant it to.
# turn the free credential off for the default account in every namespace$ for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); dokubectl patch serviceaccount default -n "$ns" \-p '{"automountServiceAccountToken": false}'doneserviceaccount/default patchedserviceaccount/default patched...# verify no default account is still handing out tokens automatically$ kubectl get sa default -A \-o jsonpath='{range .items[*]}{.metadata.namespace}{"\t"}{.automountServiceAccountToken}{"\n"}{end}'default falsekube-system falsepayments falseprod false
The default ServiceAccount is where accidents concentrate. If it can read secrets, every careless pod inherits that power. Empty its RoleBindings and stop mounting its token.
Bound service account tokens with audiences and expirations beat the old forever secret tokens. If you still see a Secret-backed token for a ServiceAccount, rotate the pattern.
One account, one job. Controllers, cron jobs, and human deployers should not share an identity just because YAML copy-paste is easy.
Projected tokens can be audience-bound to the API server so a stolen token is less useful against other audiences. Combine that with short expiry and you turn a stolen file into a race the attacker often loses. Still delete the mount entirely when the app never calls the API.
Try this
Disable automount on the default ServiceAccount, opt one API-talking pod back in with its own account, and prove the token path is gone elsewhere.
$ kubectl -n payments patch sa default -p '{"automountServiceAccountToken":false}'serviceaccount/default patched$ kubectl -n payments run noapi --image=busybox:1.36 --restart=Never -- \wget -qO- --timeout=2 file:///var/run/secrets/kubernetes.io/serviceaccount/token || echo NO_TOKENNO_TOKEN$ kubectl -n payments apply -f - <<'EOF'apiVersion: v1kind: ServiceAccountmetadata: { name: payments-api }---apiVersion: v1kind: Podmetadata: { name: with-token }spec:serviceAccountName: payments-apiautomountServiceAccountToken: truecontainers:- name: cimage: busybox:1.36command: ["sleep","3600"]EOFpod/with-token created$ kubectl -n payments exec with-token -- ls /var/run/secrets/kubernetes.io/serviceaccountca.crtnamespacetoken
Takeaway
Most pods never call the API. Turn automount off by default, give talkers their own ServiceAccount, and prefer short-lived projected tokens.