Service-account tokens
Bound tokens, automount off, minimal reach.
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.
$ kubectl exec deploy/web -n shop -- cat /var/run/secrets/kubernetes.io/serviceaccount/tokeneyJhbGciOiJSUzI1NiIsImtpZCI6Ik5nX3FpQ2sifQ.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 Verbsselfsubjectreviews.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.
$ 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 -uThu 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.
$ kubectl create token web-sa -n shop --duration=10meyJhbGciOiJSUzI1NiIsImtpZCI6Ik5nX3FpQ2sifQ.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.
$ 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:
- rule: Service-account token read by unexpected processdesc: A process other than the app entrypoint read the mounted SA tokencondition: >open_read and containerand fd.name endswith serviceaccount/tokenand 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: WARNINGtags: [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.
apiVersion: v1kind: ServiceAccountmetadata:name: frontendnamespace: shopautomountServiceAccountToken: false---$ kubectl apply -f frontend-sa.yamlserviceaccount/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 directorycommand terminated with exit code 2
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.