Service accounts
Pod identity and the token that comes with it.
Every Pod in your cluster is logged in to the API server, whether it needs to be or not. A Pod is the smallest thing Kubernetes runs, a wrapper around one or more containers that share a network address and storage. The API server is the cluster's only front door, the one service that every command and every workload talks to. The login each Pod carries is called a service account, and on a stock cluster it arrives already mounted inside the container. So a container that gets compromised starts out holding a working cluster credential, and what that costs you depends entirely on what the account behind it is allowed to do.
A service account is an identity for a workload, nothing more. Humans authenticate to the API server with client certificates or single sign-on. A Pod can't type a password, so Kubernetes hands it a service account and a token that proves which account it is. Every namespace ships with one named default, and if you don't ask for a different account, that's the one your Pod runs as. The proof of identity shows up inside the container as a file at /var/run/secrets/kubernetes.io/serviceaccount/token. It's a bearer token, which means whoever holds the string is treated as that identity, with no other check. What the token can actually do is decided somewhere else entirely, by RBAC (Role-Based Access Control), the rules that map each identity to the actions it's allowed. A token for an account with no rules bound to it is nearly worthless. A token for an account bound to cluster-admin is the entire cluster written on a sticky note.
The token, and who puts it there
Here's what happens the moment a Pod starts. The kubelet (the Kubernetes agent running on every node) asks the API server for a token on the Pod's behalf, through an endpoint called the TokenRequest API. The API server mints a signed JSON Web Token (JWT): tagged with an audience that says which service it's valid for, tied to that one Pod, and stamped with an expiry. The kubelet asks for about an hour and rewrites the file on roughly that cadence. The expiry actually written into the token is usually much longer, and this trips people up. kube-apiserver runs with --service-account-extend-token-expiration=true by default, which stretches these kubelet-requested tokens out to a year so that applications which read the file once at startup keep working while their owners fix them. Read the hourly number as the rotation interval, not as the moment the token stops being accepted. This is still a real improvement over how it used to work. Before Kubernetes v1.24, each service account came with a permanent token saved in a Secret that never expired and wasn't attached to any Pod. Those were a gift to anyone who got hold of one. The modern projected token rotates on its own, dies when the Pod dies, and only works against the audience it was minted for.
apiVersion: v1kind: ServiceAccountmetadata:name: payments-apinamespace: payments---apiVersion: v1kind: Podmetadata:name: checkoutnamespace: paymentsspec:serviceAccountName: payments-api # its own identity, not "default"automountServiceAccountToken: true # this Pod does call the APIcontainers:- name: appimage: registry.k8s.io/pause:3.9
serviceaccount/payments-api createdpod/checkout createdNAME SECRETS AGEdefault 0 9dpayments-api 0 4s
Look at the SECRETS column: it reads 0. On an older cluster that number would have been 1, because a permanent token Secret got created and stapled to the account automatically. Since v1.24 that doesn't happen, and it's a good thing. The Pod still gets a working token, just a projected, expiring one that never sits in etcd (the cluster's key-value database). You can mint one yourself the same way the kubelet does, which is the fastest way to see what an account is holding:
kubectl create token payments-api -n payments
eyJhbGciOiJSUzI1NiIsImtpZCI6IjRfN2sy...<snip>...t3Q9fZ4Ssw
# a JWT drops the "=" padding off each segment, which makes base64 -d# complain, so hand the decoding to jq insteadkubectl create token payments-api -n payments \| cut -d. -f2 \| jq -R '@base64d | fromjson'
{"aud": ["https://kubernetes.default.svc"],"exp": 1752681600,"iat": 1752678000,"iss": "https://kubernetes.default.svc","jti": "0d4d8f0a-2b1c-4c7e-9a3f-6b2c1d4e5f60","kubernetes.io": {"namespace": "payments","serviceaccount": {"name": "payments-api","uid": "a1b2c3d4-e5f6-4a7b-8c9d-0e1f2a3b4c5d"}},"nbf": 1752678000,"sub": "system:serviceaccount:payments:payments-api"}
Read that payload like an ID card. sub is the identity, always in the shape system:serviceaccount:<namespace>:<name>, and that exact string is what RBAC rules point at. aud is the audience, the service this token is allowed to speak to. exp is the second it stops working, and on a token you mint by hand like this one that's an hour out: 1752681600 minus 1752678000 is 3600. Those three fields are why a stolen modern token hurts less than a stolen legacy one. It names a narrow identity. It only works against one audience. And it has an end at all, which the legacy Secret token never did: this one dies in an hour, and the token the kubelet projects into a Pod stops being accepted the moment that Pod is deleted, whatever its exp says. But the token still only matters as much as its permissions, so ask the API server what those are:
kubectl auth can-i --list \--as=system:serviceaccount:payments:payments-api -n payments
Resources Non-Resource URLs Resource Names Verbsselfsubjectaccessreviews.authorization.k8s.io [] [] [create]selfsubjectrulesreviews.authorization.k8s.io [] [] [create]selfsubjectreviews.authentication.k8s.io [] [] [create][/api/*] [] [get][/healthz] [] [get]
That's the whole reach of a brand-new account: ask the API server about itself, read a couple of public endpoints, and nothing more. If an attacker popped this container and grabbed the token, they'd walk away with essentially nothing. That's exactly the outcome you want. Now take a Pod running as default in a namespace where somebody, months back, bound default to a broad role so a one-off script would work. Same stolen token, wildly different day. The identity is cheap. The RBAC behind it is the whole cost.
Shrinking the blast radius
You've got two levers, and both are cheap. First: if a workload never calls the Kubernetes API (and a large share of application Pods never do), don't mount a token at all. Set automountServiceAccountToken: false on the account or the Pod, and there's simply no credential in the container to steal. Second: give each workload its own service account bound to the smallest set of RBAC rules it genuinely needs, instead of sharing default or reusing one over-powered account across a namespace. The everyday version of this is obvious. You don't hand every contractor the master key to the building because one of them needs the supply closet. Same instinct here. A compromised Pod should open only the few doors that one workload actually uses, which keeps the break-in stuck inside that container instead of spreading to the cluster.
One failure mode catches people out. The kubelet swaps the token file on disk roughly every hour. Well-behaved clients (anything built on the official client-go library) re-read the file automatically. Custom code that reads the token once at startup and caches the string keeps presenting a token that is no longer the one on disk. On a default cluster that often doesn't break anything for a long time, because the extended expiry means the cached string is still valid, and the API server simply counts the call in its serviceaccount_stale_tokens_total metric and marks the request in the audit log. Watch that metric: it names the workloads that are living on borrowed time. Where the extension has been turned off, or where the Pod mounts its own projected token with an explicit expirationSeconds (which the API server does not extend), the same code starts returning 401 Unauthorized shortly after the file rotates. The fix in both cases is to re-read the file on each request, or hand the job to a real Kubernetes client and stop caching the raw string.
kubernetes.io/service-account-token annotated with the account name. People do it, usually so a CI job or an old dashboard has a 'stable' token that doesn't keep changing. That token never expires and never rotates, and it sits in etcd until someone remembers to delete it. If it leaks, it's valid forever with whatever RBAC the account holds. Use kubectl create token when you need a short-lived one, use workload identity federation for external systems, and keep long-lived service-account tokens out of pipeline variables and config files.One field in that payload is worth using on purpose: aud. The API server only accepts a token whose audience names the API server, and any service that validates tokens through the TokenReview API only accepts one minted for its own audience. So when a Pod has to authenticate to something other than the API server, don't reuse the mounted token. Add a second serviceAccountToken projected volume with an audience naming that service, and the token it hands over is dead weight everywhere else, including against your cluster. Legacy Secret-based tokens have none of this, which is the other reason to go looking for them in an old cluster and delete them.
Try this
Carry on from the account you created above. Give payments-api a Role that can only read Pods in its own namespace, run a Pod that has a shell in it, and use the projected token from inside that container to call the API twice: once against the namespace it's allowed to read, once against kube-system. Then run a second Pod with automount switched off and go looking for the token that isn't there.
$ kubectl create role pod-reader -n payments \--verb=get,list --resource=podsrole.rbac.authorization.k8s.io/pod-reader created$ kubectl create rolebinding payments-api-pod-reader -n payments \--role=pod-reader --serviceaccount=payments:payments-apirolebinding.rbac.authorization.k8s.io/payments-api-pod-reader created$ kubectl run apicheck -n payments --image=curlimages/curl:8.9.1 \--overrides='{"apiVersion":"v1","spec":{"serviceAccountName":"payments-api"}}' \--command -- sleep 3600pod/apicheck created$ kubectl wait --for=condition=Ready pod/apicheck -n payments --timeout=60spod/apicheck condition met$ kubectl exec -n payments apicheck -- sh -c 'SA=/var/run/secrets/kubernetes.io/serviceaccountfor NS in payments kube-system; docurl -sS --cacert $SA/ca.crt \-H "Authorization: Bearer $(cat $SA/token)" \-o /dev/null -w "$NS -> %{http_code}\n" \https://kubernetes.default.svc/api/v1/namespaces/$NS/podsdone'payments -> 200kube-system -> 403$ kubectl run noapi -n payments --image=curlimages/curl:8.9.1 \--overrides='{"apiVersion":"v1","spec":{"automountServiceAccountToken":false}}' \--command -- sleep 3600pod/noapi created$ kubectl exec -n payments noapi -- ls /var/run/secrets/kubernetes.io/serviceaccountls: /var/run/secrets/kubernetes.io/serviceaccount: No such file or directorycommand terminated with exit code 1
Takeaway
ServiceAccounts are pod identity. The projected token is a credential — mount it only when the app calls the API.