Service accounts & tokens

Disable automount; scope and bind narrowly.

Advanced12 min · lesson 8 of 24

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. Think of it as a keycard the building hands out in every room, including the storage closets and stairwells nobody ever badges into. A keycard for a room with nothing worth reaching buys you no convenience and hands an intruder a spare key. So the cheapest hardening you'll do all week is to stop printing the ones nobody swipes. It cuts blast radius for free, too. A pod with no token on disk can still be compromised, but the attacker inside it has no cluster identity to escalate from and has to find another way out. That single missing file turns a lot of 'pop a pod, own the namespace' chains into dead ends.

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.

serviceaccount.yaml
apiVersion: v1
kind: ServiceAccount
metadata: { name: payments-api, namespace: payments }
automountServiceAccountToken: false # default off for this account
---
apiVersion: v1
kind: Pod
metadata: { name: payments-api, namespace: payments }
spec:
serviceAccountName: payments-api
automountServiceAccountToken: false # per-pod opt-out wins over the SA setting
containers:
- { name: app, image: registry.internal/payments-api:1.4.2 }
terminal
# apply it, then confirm the container has no token to steal
$ kubectl apply -f serviceaccount.yaml
serviceaccount/payments-api created
pod/payments-api created
$ kubectl exec payments-api -n payments -- ls /var/run/secrets/kubernetes.io/serviceaccount
ls: /var/run/secrets/kubernetes.io/serviceaccount: No such file or directory
command 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, like a tamper-evident wristband with your name and access level printed right on it) 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 swaps that permanent brass key for a hotel keycard that stops working at checkout. These are bound tokens. The kubelet asks the TokenRequest API for a short-lived token scoped to a specific audience (the audience is 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.

terminal
# 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

RBAC (Role-Based Access Control) is the bouncer's guest list. The token proves who you are; RBAC decides what that identity is allowed to do once it's through the door. Keeping the two separate is the whole point, and confusing them is how people end up carefully 'protecting' a token that had cluster-admin behind it all along. 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.

terminal
# 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 -> edit
frontend -> view
# every other account in omni, including default, is absent here, so it carries no permissions
terminal
# give the account exactly one job: read configmaps in its own namespace
$ kubectl create role cfg-reader -n payments \
--verb=get,list --resource=configmaps
role.rbac.authorization.k8s.io/cfg-reader created
$ kubectl create rolebinding payments-api-cfg -n payments \
--role=cfg-reader --serviceaccount=payments:payments-api
rolebinding.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-api
yes
$ kubectl auth can-i list secrets -n payments \
--as=system:serviceaccount:payments:payments-api
no
ONE QUESTION DECIDES THE TOKEN
Does this pod call the Kubernetes API?
ask it before every workload ships
No (most pods)
automountServiceAccountToken: false
no token on disk, nothing to steal or replay
Yes
Dedicated account + narrow Role, opt back in
never the default account, never a broad ClusterRoleBinding
Automount off is not the same as no access
Turning off the mount deletes the free copy from the container filesystem. It does not touch the account's RBAC or block the TokenRequest API. Anyone who can create a pod in that namespace can set serviceAccountName back and mount a token, and kubectl create token still mints one on demand. Treat automount as defense in depth, and pair it with tight RBAC plus a policy on who may create pods and request tokens. One more trap: flipping the flag on the default account does not unmount tokens from pods already running, so restart them before you call it done.

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.

terminal
# turn the free credential off for the default account in every namespace
$ for ns in $(kubectl get ns -o jsonpath='{.items[*].metadata.name}'); do
kubectl patch serviceaccount default -n "$ns" \
-p '{"automountServiceAccountToken": false}'
done
serviceaccount/default patched
serviceaccount/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 false
kube-system false
payments false
prod 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.

terminal
$ 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_TOKEN
NO_TOKEN
$ kubectl -n payments apply -f - <<'EOF'
apiVersion: v1
kind: ServiceAccount
metadata: { name: payments-api }
---
apiVersion: v1
kind: Pod
metadata: { name: with-token }
spec:
serviceAccountName: payments-api
automountServiceAccountToken: true
containers:
- name: c
image: busybox:1.36
command: ["sleep","3600"]
EOF
pod/with-token created
$ kubectl -n payments exec with-token -- ls /var/run/secrets/kubernetes.io/serviceaccount
ca.crt
namespace
token

Takeaway

Most pods never call the API. Turn automount off by default, give talkers their own ServiceAccount, and prefer short-lived projected tokens.

Quick check
01You disabled automountServiceAccountToken on a namespace default account, yet an attacker who lands in a pod there still pulls a working API token. How?
Correct — Automount is defense in depth, not authorization. Guard the account's RBAC and who may create pods too.
Incorrect — No, it has worked for years, both on the account and on the pod.
Incorrect — They respect it. With automount off, no token is projected into the container.
Incorrect — The modern model has no static token Secret to delete; tokens are minted on demand.
02How does a modern bound service-account token differ from the legacy Secret-based token in a way that limits a stolen copy?
Incorrect — the legacy token was the one that never expired; bound tokens are deliberately short-lived.
Incorrect — that is the legacy model; bound tokens are minted on demand rather than parked in a Secret.
Incorrect — a bound token is tied to the pod and expires; it does not simply live as long as the account.
Correct — bound tokens rotate before expiry, are audience-scoped, and are invalidated when their pod goes away.
03A ServiceAccount sets automountServiceAccountToken: false. A pod that uses that account sets automountServiceAccountToken: true in its own spec. Does the container get a token mounted?
Incorrect — precedence runs the other way; the pod-level value wins over the account default.
Correct — the per-pod value overrides the SA default, which is exactly how you carve out the one workload that truly needs a token.
Incorrect — both locations are allowed by design; the more specific pod setting simply takes precedence.
Incorrect — service-account-lookup governs account re-checking on the API server, not whether a token is mounted.

Related