ServiceAccount and Bound Tokens

K
Kai··5 min read

The two previous articles used ServiceAccount as a ready-made identity, but didn't explain how it works inside: where the token comes from, how the pod receives it, how long it lives. This article dissects that, and proves by hand that the token injected into a pod is invalidated the moment the pod disappears.

The default SA and the auto-injected token

Every namespace, when created, automatically gets a ServiceAccount named default. Any pod that doesn't declare a serviceAccountName uses default:

kubectl create namespace sa-demo
kubectl -n sa-demo get sa
NAME      AGE
default   0s

Create a pod (no SA declared, so it gets default) and look at what's inside the pod at the conventional token path:

kubectl -n sa-demo run app --image=registry.k8s.io/e2e-test-images/agnhost:2.45 --command -- sleep 100000
kubectl -n sa-demo exec app -- ls /var/run/secrets/kubernetes.io/serviceaccount/
kubectl -n sa-demo exec app -- cat /var/run/secrets/kubernetes.io/serviceaccount/namespace
ca.crt
namespace
token
sa-demo

Three files: token (the JWT to call the API), ca.crt (the CA to trust the API server), namespace (the pod's namespace). Nobody created a Secret for this — the kubelet injects them via a projected volume it adds to the pod itself. Look at that volume in the spec:

kubectl -n sa-demo get pod app -o jsonpath='{.spec.volumes[?(@.projected)].projected.sources}'
[ {"serviceAccountToken": {"expirationSeconds": 3607, "path": "token"}},
  {"configMap": {"name": "kube-root-ca.crt", "items": [{"key":"ca.crt","path":"ca.crt"}]}},
  {"downwardAPI": {"items": [{"fieldRef": {"fieldPath":"metadata.namespace"}, ...}]}} ]

That's the projected volume from Article 41, this time set up by the kubelet: the serviceAccountToken source requests a token living 3607 seconds, the configMap source pulls the CA, the downwardAPI source writes the namespace. The token is the modern bound kind — the kubelet requests it via the TokenRequest API and refreshes the file before it expires, completely unlike the old never-expiring token stored in a Secret (Kubernetes no longer creates those automatically since 1.24).

Token bound to pod and node

Decode the payload of the token inside the pod to see how far it's bound:

kubectl -n sa-demo exec app -- cat /var/run/secrets/kubernetes.io/serviceaccount/token \
  | cut -d. -f2 | base64 -d | python3 -m json.tool
"aud": ["https://10.0.1.10:6443"],
"sub": "system:serviceaccount:sa-demo:default",
"iat": 1779578851,
"exp": 1811114851,
"kubernetes.io": {
  "namespace": "sa-demo",
  "node": {"name": "worker-0", "uid": "b5766c61-..."},
  "pod":  {"name": "app", "uid": "a6ab2be4-..."},
  "serviceaccount": {"name": "default", "uid": "7da7eed9-..."},
  "warnafter": 1779582458
}

Unlike the standalone token in Article 51, the kubernetes.io claim here records the specific pod and node with their UIDs. This token belongs to pod app on worker-0. warnafter (= iat + 3607, about 1 hour) is when the kubelet refreshes the file; exp, set much farther out, is a grace window so an old client that hasn't re-read the file yet doesn't break immediately. The important thing isn't the exp number: because the token carries the pod UID, the API server also checks whether that pod still exists.

Proof: delete the pod, the token dies with it

Grab the token in the running pod, then call the API server with it:

TOK=$(kubectl -n sa-demo exec app -- cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -s -o /dev/null -w "HTTP %{http_code}\n" --cacert ca.pem \
  -H "Authorization: Bearer $TOK" https://<apiserver>/api/v1/namespaces/sa-demo/pods
kubectl --kubeconfig=/dev/null --token="$TOK" --server=https://<apiserver> \
  --certificate-authority=ca.pem auth whoami | grep Username
HTTP 403
Username   system:serviceaccount:sa-demo:default

403 not 401: the token is valid, the API server authenticates it as system:serviceaccount:sa-demo:default, it's just that the default SA has no RBAC so it can't list pods. Now delete the pod, then reuse that exact same token:

kubectl -n sa-demo delete pod app
curl -s --cacert ca.pem -H "Authorization: Bearer $TOK" \
  https://<apiserver>/api/v1/namespaces/sa-demo/pods
{ "code": 401, "message": "Unauthorized" }

401 — the token that worked a moment ago is now rejected right at authentication, because the pod it's bound to has disappeared. This is the big difference from the old never-expiring token: a leaked old token stayed usable until you deleted the Secret; a bound token dies with the pod, so the risk window when leaked shrinks to the pod's lifetime.

Turn off auto-mount

Not every pod needs to call the API. If it doesn't, the best move is not to inject a token at all — one less thing that can leak. Set automountServiceAccountToken: false (on the SA or on the pod):

kubectl -n sa-demo run app-notoken --image=registry.k8s.io/e2e-test-images/agnhost:2.45 \
  --overrides='{"spec":{"automountServiceAccountToken":false}}' --command -- sleep 100000
kubectl -n sa-demo exec app-notoken -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: /var/run/secrets/kubernetes.io/serviceaccount/: No such file or directory

No more token directory. A workload that doesn't call the API yet still carries a token is excess attack surface, so turning off auto-mount for those pods is a worthwhile hardening step.

🧹 Cleanup

kubectl delete namespace sa-demo

Deleting the namespace takes the pod and the default SA with it (the control plane will recreate default if the namespace stays). The commands used here are at github.com/nghiadaulau/kubernetes-from-scratch, folder 53-serviceaccounts.

Wrap-up

Every namespace has a default SA, and a pod that declares no SA uses it. The kubelet injects a token into the pod via a self-built projected volume (serviceAccountToken + kube-root-ca.crt + downwardAPI namespace), placed at /var/run/secrets/kubernetes.io/serviceaccount/. The token is the bound kind: the kubernetes.io claim records the pod's and node's UIDs, warnafter marks when the kubelet refreshes, and the API server checks whether the pod still exists. We proved it by hand: the token in the pod calls the API with 403 (valid, just missing RBAC), delete the pod and reuse the same token gives 401 — the token dies with the pod. Compared to the old never-expiring Secret token, the bound token narrows the risk window down to the pod's lifetime. For a pod that doesn't need to call the API, set automountServiceAccountToken: false to avoid injecting an unnecessary token.

By now the cluster has authentication (Article 51), authorization (Article 52), and workload identity (Article 53), all at the API level. Article 54 moves down to the pod itself: Pod Security Standards and admission blocking, right at creation time, pods that request dangerous privileges — running as root, privileged, borrowing the host's namespaces.