Service-account tokens

Bound tokens, automount off, minimal reach.

Advanced30 min · lesson 5 of 15

The first file an intruder reads inside a compromised pod is almost always the same one: /var/run/secrets/kubernetes.io/serviceaccount/token. That file is a working credential. Whoever holds it can talk to the Kubernetes API server as the pod's identity and do everything that identity is permitted to do. Two kinds of building access make the risk concrete. One is a visitor pass the front desk prints on the spot, stamped "expires 5pm", tied to your name and today's date. The other is a permanent staff badge someone left in an unlocked desk drawer. Both open doors right now. Only one of them is still a problem next month. Service-account tokens come in both shapes, and the shape you hand your pods decides how much a single break-in is worth.

A Service Account (SA) is the identity a pod uses to prove who it is to the cluster, the same way a login is the identity you use. Every request a pod sends to the API server (the cluster's front door, the one process every command has to pass through) carries its token, the server reads the identity out of that token, and then RBAC (Role-Based Access Control, the rules that say which identity may do what) decides allow or deny. The token itself is a JWT (JSON Web Token): a long base64 string in three dot-separated parts, a header, a payload of claims, and a signature the API server verifies. The payload is not encrypted, only encoded. Anyone holding the token can read exactly who it belongs to and the minute it dies.

Read the badge from inside the pod

From a foothold in a container, stealing the identity takes one read of a file. The interesting question for both attacker and defender is the next one: what does this token actually open? You answer that with kubectl auth can-i, which asks the API server to enumerate the permissions of a given token without touching anything. Run it against a pod's token and you see its real reach, not the reach you assumed at deploy time.

the token is a file; read it, then ask what it opens
$ kubectl exec deploy/web -n shop -- cat /var/run/secrets/kubernetes.io/serviceaccount/token
eyJhbGciOiJSUzI1NiIsImtpZCI6Ik5nX3FpQ2sifQ.eyJhdWQiOlsiaHR0cHM6...Q0g2Zw
$ TOKEN=$(kubectl exec deploy/web -n shop -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
$ kubectl auth can-i --list --token="$TOKEN"
Resources Non-Resource URLs Resource Names Verbs
selfsubjectreviews.authentication.k8s.io [] [] [create]
secrets [] [] [get list]
pods [] [] [get list watch]
configmaps [] [] [get list]
[/healthz] [] [get]

That output is the whole ballgame. This token can list every Secret in the shop namespace. Secrets hold database passwords, API keys, and often other tokens, so an attacker who owns this one pod now owns a great deal more. To understand why the credential is (or isn't) dangerous past today, decode the token and read its claims. The payload is the middle segment, so cut it out and base64-decode it.

decode the mounted token: audience, subject, expiry, bound object
$ echo "$TOKEN" | cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, sub, exp, bound: ."kubernetes.io"}'
{
"aud": ["https://kubernetes.default.svc"],
"sub": "system:serviceaccount:shop:web-sa",
"exp": 1784214000,
"bound": {
"namespace": "shop",
"node": { "name": "ip-10-0-3-14" },
"pod": { "name": "web-6f8c9d7b5-2xk4p", "uid": "d1f2a7c8-6b40-4a19-9e02-7c1e5b8a3f11" },
"serviceaccount": { "name": "web-sa", "uid": "a3c90b12-4d55-4c8a-9b21-0f7e9d2c4a80" }
}
}
$ date -d @1784214000 -u
Thu Jul 16 15:00:00 UTC 2026

Three claims matter. aud (audience) says who the token is for. This one is only valid against the cluster's own API server, so it can't be replayed at some other service that shares the same signing key. exp is the expiry, one hour out here, so a leaked copy stops working at 3pm no matter what the thief does. And the kubernetes.io block is the bound object: the token is tied to this specific pod's UID (its unique ID). That is the modern projected token, and it is the visitor pass. The kubelet (the agent on each node) requested it from the TokenRequest API on the pod's behalf, mounted it as a projected volume, and quietly rotates it before it expires. Delete the pod and the API server starts rejecting the token, because it checks that the bound pod still exists before honoring it.

Older clusters worked the other way. For every service account Kubernetes auto-created a Secret holding a token with no expiry, no audience limit, and no binding to any pod. That is the permanent badge in the drawer: steal it once and it works forever, from anywhere, until a human notices and rotates it by hand. Bound tokens shrank that blast radius enormously. When an external system genuinely needs to authenticate as an SA, say a continuous-integration (CI) runner or a monitoring agent, you don't hand it a static Secret, you mint a short-lived token on demand and let it expire.

mint a bound token on demand with a hard expiry
$ kubectl create token web-sa -n shop --duration=10m
eyJhbGciOiJSUzI1NiIsImtpZCI6Ik5nX3FpQ2sifQ.eyJhdWQiOlsiaHR0cHM6...bX2Zn
$ kubectl create token web-sa -n shop --duration=10m \
| cut -d. -f2 | base64 -d 2>/dev/null | jq '{aud, exp, bound: ."kubernetes.io"}'
{
"aud": ["https://kubernetes.default.svc"],
"exp": 1784211000,
"bound": {
"namespace": "shop",
"serviceaccount": { "name": "web-sa", "uid": "a3c90b12-4d55-4c8a-9b21-0f7e9d2c4a80" }
}
}

Notice what the minted token's bound block is missing: there's no pod, no node. Because you didn't pass --bound-object-ref, it's tied only to the service account, so nothing gets deleted to cut it off early. It simply runs for its ten minutes and dies. That's fine for a controlled, on-demand handoff, and it's the reason you should never fall back to a permanent Secret. Now the defensive half: seeing this happen when it shouldn't, and removing the credential from pods that never needed one.

See the theft, then take the badge away

You can't stop a compromised container from reading a file it has mounted. What you can do is notice, and make the credential worth almost nothing. Two detections cover the two moves. Minting a token shows up in the API server audit log (the record of every request) as a create on the token subresource, so anyone forging an on-demand badge leaves a trace. And reading the mounted file is a system call, which is exactly what Falco (a runtime tool that watches Linux syscalls and alerts on suspicious ones) is built to catch.

detect: audit log shows who minted a token for which SA
$ jq 'select(.objectRef.resource=="serviceaccounts" and .objectRef.subresource=="token")
| {t:.requestReceivedTimestamp, who:.user.username, sa:.objectRef.name, ip:.sourceIPs[0], code:.responseStatus.code}' \
/var/log/kubernetes/audit.log
{
"t": "2026-07-16T14:19:02Z",
"who": "system:serviceaccount:shop:ci-deployer",
"sa": "cluster-admin-sa",
"ip": "10.0.9.44",
"code": 201
}

Read that alert like a tripwire. A modest CI service account just minted a token for cluster-admin-sa and got a 201 (created). Legitimate pipelines mint tokens for their own low-privilege SA, not for the most powerful identity in the cluster, so this line is worth a page. The runtime side catches the theft itself:

detect: Falco rule for an unexpected process reading the token
- rule: Service-account token read by unexpected process
desc: A process other than the app entrypoint read the mounted SA token
condition: >
open_read and container
and fd.name endswith serviceaccount/token
and not proc.name in (node, java, python3)
output: >
SA token read (proc=%proc.name pod=%k8s.pod.name ns=%k8s.ns.name file=%fd.name)
priority: WARNING
tags: [k8s, credential_access]
# Falco emits when a shell in the pod cats the token:
14:22:03 Warning SA token read (proc=curl pod=web-6f8c9d7b5-2xk4p ns=shop file=/run/secrets/kubernetes.io/serviceaccount/token)

The fix has two levers, and you pull both. If a pod never calls the Kubernetes API, give it no token to steal. Setting automountServiceAccountToken to false on the service account removes the mount entirely, so there is nothing at that path to read. For pods that do call the API, keep the SA's RBAC as tight as the auth can-i output demands and no wider. Together they mean a popped pod either has no badge or a badge that opens one small door.

fix: remove the credential where the API is never used
apiVersion: v1
kind: ServiceAccount
metadata:
name: frontend
namespace: shop
automountServiceAccountToken: false
---
$ kubectl apply -f frontend-sa.yaml
serviceaccount/frontend configured
$ kubectl exec deploy/frontend -n shop -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory
command terminated with exit code 2
Deleting the pod doesn't always kill the token
A projected token mounted into a pod is bound to that pod, so once you delete the pod the API server rejects the token even before its expiry. But a token you mint with kubectl create token web-sa (no --bound-object-ref) is bound only to the service account. It stays valid for its full --duration after every pod is gone and after the person who ran the command has logged off, and it never appears in kubectl get secret, so it's easy to forget it exists. Treat on-demand minted tokens like the visitor pass you forgot to collect at the door: keep the duration short and don't mint them for privileged SAs unless you're watching the audit log.
What token posture does this pod deserve?
A pod is being deployed
decide its badge before it ships
never calls the API
automountServiceAccountToken: false
no token on disk; the drawer is empty
calls the API in-cluster
default projected token + minimal RBAC
short-lived, audience-scoped, dies with the pod
external system must auth
kubectl create token --duration
time-boxed visitor pass; rotate, never a static Secret
legacy Secret token found
delete it, move to TokenRequest
non-expiring badge left in an unlocked drawer
Every pod lands in one of these branches. The only wrong answer is a broad, non-expiring token mounted into a workload that didn't need one.
Quick check
01You set automountServiceAccountToken: false on a ServiceAccount to stop handing out API credentials, yet a pod using that SA still has a readable token at /var/run/secrets/kubernetes.io/serviceaccount/token. What is the most likely cause?
Correct — The SA setting is only a default. A pod-level setting overrides it, and mesh or secrets sidecars commonly inject their own token volume, so the credential is back despite your change.
Incorrect — automountServiceAccountToken is honored on every supported version; bound (projected) tokens respect it exactly like the old ones did.
Incorrect — The mount is decided per pod at creation. Recreating the pod is enough, and there's nothing node-level about it.
Incorrect — auth can-i only reads permissions; it never mints or mounts tokens. Nothing about a permission check puts a file on disk.
02A modern projected service-account token stops working the instant you delete its pod, even before the token's exp (expiry) time. Why?
Incorrect — The ServiceAccount is a separate object that outlives the pod, so that isn't why the token dies.
Incorrect — Wiping the local file wouldn't invalidate a copy an attacker already exfiltrated; the invalidation happens server-side.
Correct — projected tokens carry a kubernetes.io bound-object reference, so once the pod is gone the server rejects the token.
Incorrect — base64 is only encoding and never expires; validity comes from the signature, the exp claim, and the pod binding.
03An engineer runs kubectl create token web-sa -n shop --duration=30m for a one-off task, then deletes every pod that used web-sa. Is that credential now dead?
Correct — with no pod binding there's nothing to delete that cuts it off early, which is exactly why on-demand minted tokens are so easy to forget.
Incorrect — That holds only for pod-bound projected tokens; a create token with no bound object ignores pod lifecycle entirely.
Incorrect — The token has no tie to the shell session; it lives for its full --duration.
Incorrect — The token carries web-sa's full permissions, so its reach is whatever that service account can do, not nothing.

A token is only ever as dangerous as the RBAC standing behind it. A bound, ten-minute, pod-scoped token wired to a Role that reads one ConfigMap is close to worthless if it leaks. The identical token shape wired to the wrong ClusterRole is cluster-admin waiting for an RCE (remote code execution, someone running their own commands on your box). Which verbs quietly turn a modest token into the whole cluster (bind, escalate, impersonate, and the very token-minting call you just watched in the audit log) is the path the next lesson walks.

Try this

Work through “See the theft, then take the badge away” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

The trap worth remembering here: deleting the pod doesn't always kill the token. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.

Related