Service accounts

Pod identity and the token that comes with it.

Intermediate10 min · lesson 47 of 65
In plain terms
A service account is a staff badge for a robot (a pod) rather than a person. By default every pod gets a badge clipped on — even the ones that never need to open a single door.

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.

app.yaml: a dedicated account and a Pod that actually uses it
apiVersion: v1
kind: ServiceAccount
metadata:
name: payments-api
namespace: payments
---
apiVersion: v1
kind: Pod
metadata:
name: checkout
namespace: payments
spec:
serviceAccountName: payments-api # its own identity, not "default"
automountServiceAccountToken: true # this Pod does call the API
containers:
- name: app
image: registry.k8s.io/pause:3.9
output: kubectl apply -f app.yaml, then kubectl get sa -n payments
serviceaccount/payments-api created
pod/checkout created
NAME SECRETS AGE
default 0 9d
payments-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:

mint a token yourself, the same call the kubelet makes for a Pod
kubectl create token payments-api -n payments
output: a signed, short-lived JWT bearer token (truncated)
eyJhbGciOiJSUzI1NiIsImtpZCI6IjRfN2sy...<snip>...t3Q9fZ4Ssw
decode the middle segment to read the claims inside
# a JWT drops the "=" padding off each segment, which makes base64 -d
# complain, so hand the decoding to jq instead
kubectl create token payments-api -n payments \
| cut -d. -f2 \
| jq -R '@base64d | fromjson'
output: what the API server actually stamped into the token
{
"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:

ask the API server exactly what this account is allowed to do
kubectl auth can-i --list \
--as=system:serviceaccount:payments:payments-api -n payments
output: a fresh account with no roles bound reaches almost nothing
Resources Non-Resource URLs Resource Names Verbs
selfsubjectaccessreviews.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.

How a Pod's token gets there, and how it's checked
1Pod startsruns as its SA2kubelet asksTokenRequest to API server3token projectedfile rewritten ~hourly4Pod calls APIsent as a bearer token5authn + RBACidentity + rules decide
The kubelet fetches and keeps refreshing the token; the API server checks who you are, then RBAC decides what you can do. Where a Pod never calls the API, turn the token off and there's nothing to steal.

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.

A hand-made token Secret is a permanent key you'll forget you cut
Since v1.24 Kubernetes stopped auto-creating those never-expiring token Secrets, but you can still make one by hand: a Secret of type 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.

terminal
$ kubectl create role pod-reader -n payments \
--verb=get,list --resource=pods
role.rbac.authorization.k8s.io/pod-reader created
$ kubectl create rolebinding payments-api-pod-reader -n payments \
--role=pod-reader --serviceaccount=payments:payments-api
rolebinding.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 3600
pod/apicheck created
$ kubectl wait --for=condition=Ready pod/apicheck -n payments --timeout=60s
pod/apicheck condition met
$ kubectl exec -n payments apicheck -- sh -c '
SA=/var/run/secrets/kubernetes.io/serviceaccount
for NS in payments kube-system; do
curl -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/pods
done'
payments -> 200
kube-system -> 403
$ kubectl run noapi -n payments --image=curlimages/curl:8.9.1 \
--overrides='{"apiVersion":"v1","spec":{"automountServiceAccountToken":false}}' \
--command -- sleep 3600
pod/noapi created
$ kubectl exec -n payments noapi -- 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

Takeaway

ServiceAccounts are pod identity. The projected token is a credential — mount it only when the app calls the API.

Quick check
01You set automountServiceAccountToken: false on a service account, but a Pod using that account still has a token mounted at /var/run/secrets/... Why?
Correct — The Pod-level field is the more specific one and wins over the service account default, so the token comes right back. Set it in both places, or leave the Pod field unset.
Incorrect — There's no restart requirement. The setting is applied at admission time when the Pod is created, not gated on a control-plane restart.
Incorrect — It works on any service account and on any Pod spec. There is no default-only restriction.
Incorrect — The projected token is a volume injected by the kubelet at runtime, never part of the image. Images don't ship service-account tokens.
02Compared with a pre-v1.24 token stored in a Secret, what makes a modern projected service-account token less useful to an attacker who steals it?
Incorrect — the token is a readable bearer string; its safety comes from its claims, not from encryption.
Incorrect — permissions come from RBAC bindings and apply immediately; that's the same for both token types.
Correct — the exp, aud and bound-object claims mean a stolen projected token is narrow and time-boxed, and it is refused once its Pod is gone. A legacy Secret token was none of those.
Incorrect — the sub claim names the exact account (system:serviceaccount:namespace:name); the token doesn't anonymize identity.
03A long-running Pod authenticates to the API server fine at startup, then starts getting 401 Unauthorized later on, with no config changes. Its Pod spec mounts its own projected token with an explicit expirationSeconds, so the API server never extends the expiry. What's the usual cause?
Incorrect — deleting the account would break access immediately, not cleanly some time after startup.
Correct — the kubelet rewrites the file when the token rotates, and code that read the string once keeps sending the old one until it expires. Re-read the file on each request.
Incorrect — RBAC grants don't expire on a timer; only the token has an expiry.
Incorrect — the kubelet keeps refreshing the file for the life of the Pod; the problem is the app not reading it again.

Related