Every Pod that talks to the Kubernetes API does so as a ServiceAccount, and the credential that proves that identity is a JSON Web Token mounted inside the container. On the CKS exam — and in every real cluster — that token is one of the most attractive things an attacker can steal. Compromise a single Pod, read its mounted token, and you inherit whatever that ServiceAccount is allowed to do. If the ServiceAccount is over-permissioned, one exploited web app becomes cluster-wide access.
This topic sits across two CKS domains: Cluster Hardening (minimize permissions and restrict API access) and Minimize Microservice Vulnerabilities. It is easy to under-prepare for because ServiceAccounts feel like a CKAD-level “identity for your Pods” topic. But the CKS angle is different: it is about reducing the blast radius of a stolen token — turning off automount where it isn’t needed, preferring short-lived bound tokens over non-expiring ones, and making sure no ServiceAccount carries more RBAC than its workload requires.
If you want the whole exam picture first, start with the CKS exam guide for 2026 and sequence your prep with the CKS study plan. This guide assumes you already understand the CKAD-level basics of assigning a ServiceAccount and turning off its token — the CKAD SecurityContext & ServiceAccounts guide covers those — and focuses on the security-hardening half the CKS actually tests.
Why the Token Is the Prize
A request to the API server carries a credential; a chain of authenticators inspects it and, if valid, attaches an identity. For Pods that identity is system:serviceaccount:<namespace>:<name>, and the credential is a bearer token that the kubelet mounts into the container filesystem at a well-known path:
/var/run/secrets/kubernetes.io/serviceaccount/token
Anyone — or any process — that can read that file can become the ServiceAccount. There is no second factor. That is why the token is the prize:
# From inside a compromised pod, one file plus one curl = API access
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
CACERT=/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
curl --cacert $CACERT \
-H "Authorization: Bearer $TOKEN" \
https://kubernetes.default.svc/api/v1/namespaces/default/pods
If that call returns a Pod list, the ServiceAccount can read Pods. If it can read Secrets, the attacker just harvested every credential in the namespace. The entire hardening story below exists to make that curl return 403 Forbidden — or to make sure the token file isn’t there at all.
Bound Tokens vs. Legacy Tokens: The 1.24 Shift
This is the single most exam-relevant piece of knowledge, because the mechanics changed and the CKS tests the current behaviour.
Before Kubernetes 1.24, creating a ServiceAccount automatically created a Secret of type kubernetes.io/service-account-token. That Secret held a JWT with no expiry and no audience binding. It sat in etcd forever. Steal it once and it works until someone manually deletes the Secret and rotates it. These are the legacy tokens, and finding and eliminating them is a hardening task.
From Kubernetes 1.24 onward (the LegacyServiceAccountTokenNoAutoGeneration behaviour), creating a ServiceAccount no longer auto-generates that Secret. Instead, Pods receive bound tokens minted through the TokenRequest API. A bound token is fundamentally safer:
| Property | Legacy Secret token | Bound token (TokenRequest) |
|---|---|---|
| Expiry | Never | Short-lived (default ~1h, kubelet rotates) |
| Audience | None (usable anywhere) | Bound to a specific audience |
| Object binding | None | Bound to the Pod (and SA) object |
| Storage | Persisted in etcd as a Secret | Not stored; minted on demand |
| Revocation | Delete the Secret manually | Delete the Pod → token invalidated |
| Blast radius if stolen | Permanent | Expires; dies with the Pod |
The bound token is delivered by a projected volume rather than a Secret. The kubelet requests it from the API server, mounts it, and transparently refreshes it at roughly 80% of its lifetime. Because the token is tied to the Pod’s identity and UID, deleting the Pod makes the token useless — a property legacy tokens never had.
You can see the projection Kubernetes injects automatically; it looks like this under the hood:
volumes:
- name: kube-api-access
projected:
sources:
- serviceAccountToken:
path: token
expirationSeconds: 3607 # ~1 hour, kubelet rotates before expiry
audience: null # defaults to the API server audience
- configMap:
name: kube-root-ca.crt
- downwardAPI:
items:
- path: namespace
fieldRef:
fieldPath: metadata.namespace
Exam takeaway: on a modern cluster, a fresh ServiceAccount has no associated Secret. If you kubectl get secret and find a kubernetes.io/service-account-token Secret, that is a legacy, non-expiring credential someone created deliberately — treat it as a finding.
Minting Tokens On Demand with kubectl create token
The imperative front door to the TokenRequest API is kubectl create token. This is the exam-friendly way to obtain a bound token without creating a persistent Secret:
# A short-lived bound token for the 'build-bot' ServiceAccount
kubectl create token build-bot --duration=10m
# Bind it to a non-API audience (e.g. for Vault) so it CANNOT hit the kube API
kubectl create token build-bot --audience=vault --duration=1h
Decode the middle segment of the JWT and you can read the claims — this is worth practising so you can prove what a token is bound to:
kubectl create token build-bot --duration=10m \
| cut -d. -f2 | base64 -d 2>/dev/null | jq .
{
"aud": ["https://kubernetes.default.svc"],
"exp": 1785000000,
"sub": "system:serviceaccount:default:build-bot",
"kubernetes.io": {
"namespace": "default",
"serviceaccount": { "name": "build-bot", "uid": "..." }
}
}
The aud (audience) claim is the security lever most candidates miss. A token minted with --audience=vault carries "aud": ["vault"]. The API server rejects it because it expects its own audience. That means you can hand a workload a ServiceAccount token scoped to one downstream service, and even if it leaks, it is worthless against the Kubernetes API.
Turn Off the Token When the Pod Doesn’t Need It
The cheapest, highest-impact hardening move is to not mount the token at all. Most application Pods never call the Kubernetes API — a web frontend, a batch job, a proxy. If they don’t need the API, they shouldn’t carry a credential for it.
automountServiceAccountToken: false can be set in two places. Setting it on the ServiceAccount makes it the default for every Pod using that SA:
apiVersion: v1
kind: ServiceAccount
metadata:
name: build-bot
namespace: default
automountServiceAccountToken: false
Setting it on the Pod spec overrides the ServiceAccount and wins for that Pod specifically:
apiVersion: v1
kind: Pod
metadata:
name: frontend
spec:
serviceAccountName: build-bot
automountServiceAccountToken: false # Pod-level wins over SA-level
containers:
- name: app
image: nginx:1.27
Verify the token is genuinely gone — the mount path should not exist:
kubectl exec frontend -- ls /var/run/secrets/kubernetes.io/serviceaccount/ \
|| echo "no token mounted — good"
The precedence rule is a favourite exam trap: Pod-level automountServiceAccountToken overrides the ServiceAccount-level setting. A Pod can opt back in even when its SA opted out, and vice versa.
Don’t forget the default ServiceAccount. Every namespace has one, and Pods that don’t name a ServiceAccount use it. It carries no RBAC by default, but its token is still mounted. Harden it directly:
kubectl patch serviceaccount default \
-p '{"automountServiceAccountToken": false}'
Least Privilege: A Token Is Only As Dangerous As Its RBAC
Disabling automount handles the Pods that don’t need the API. For the ones that do, the defence is RBAC least privilege. A stolen token that maps to a ServiceAccount with no permissions returns 403 on everything — the token is real, but useless.
The command that matters here — and the one to memorise for the exam — checks exactly what a ServiceAccount can do:
# What can this ServiceAccount actually do?
kubectl auth can-i --list \
--as=system:serviceaccount:default:build-bot
# A specific, pointed check
kubectl auth can-i get secrets \
--as=system:serviceaccount:default:build-bot -n default
Audit for the two anti-patterns the CKS loves to plant:
- A ServiceAccount bound to
cluster-admin(or any wildcard ClusterRole). Grep your bindings:kubectl get clusterrolebindings -o json | jq -r ' .items[] | select(.subjects[]?.kind=="ServiceAccount") | "\(.roleRef.name) <- \(.subjects[].namespace)/\(.subjects[].name)"' - Broad
list/geton Secrets. Any SA that can list Secrets can read every credential in scope. Scope Roles to named resources withresourceNameswhere possible, and prefer namespacedRole/RoleBindingover cluster-wide grants.
This pairs directly with the broader hardening work in the CKS cluster setup & hardening guide — restricting API access and RBAC are the same discipline applied to human users and to ServiceAccounts.
The Legacy Token Hunt
On an inherited cluster, the highest-value finding is a non-expiring token. Two ways they exist:
- A leftover
kubernetes.io/service-account-tokenSecret from a pre-1.24 cluster, or one created deliberately. - A manually created Secret like this, which the controller fills with a non-expiring JWT:
apiVersion: v1
kind: Secret
metadata:
name: build-bot-static-token
annotations:
kubernetes.io/service-account.name: build-bot
type: kubernetes.io/service-account-token
Find every one of them cluster-wide:
kubectl get secrets -A \
--field-selector type=kubernetes.io/service-account-token
For each hit, decide whether a long-lived token is truly required (rarely — CI systems and external integrations are the usual justifications). If not, delete the Secret. If it is required, at least know that it never expires and that rotation is manual: delete and recreate the Secret, then update every consumer. Prefer, wherever possible, replacing static tokens with on-demand kubectl create token calls or a projected volume with a short expirationSeconds.
Putting It Together: A Hardening Checklist
A repeatable sequence you can run against any workload under time pressure:
| Step | Action | Command / field |
|---|---|---|
| 1 | Does the Pod call the API at all? | If no → automountServiceAccountToken: false |
| 2 | Give it a dedicated ServiceAccount | kubectl create sa build-bot (never reuse default) |
| 3 | Grant the minimum RBAC | Namespaced Role + RoleBinding, named resourceNames |
| 4 | Verify the grant | kubectl auth can-i --list --as=system:serviceaccount:ns:build-bot |
| 5 | Harden the namespace default SA | Patch automountServiceAccountToken: false |
| 6 | Hunt legacy tokens | kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token |
| 7 | Prefer short-lived, audience-bound tokens | kubectl create token --duration --audience |
Layer this with the other microservice-hardening controls — Pod Security Standards, mTLS between services, and network isolation — covered in the CKS minimize microservice vulnerabilities guide. A NetworkPolicy that blocks egress from a workload to the API server on kubernetes.default.svc is a strong second line of defence: even a mounted token can’t be used if the Pod can’t reach the API.
Detecting Token Abuse
Prevention reduces the blast radius; detection tells you when someone tried anyway. The API server audit log records every authenticated request, including the ServiceAccount that made it. A request from a ServiceAccount to an endpoint it has never touched before — a frontend SA suddenly listing Secrets — is a high-signal indicator of a stolen token in use. Audit policy, levels, and backends are their own CKS topic; the connection to make here is that the identity in those audit entries (user.username: system:serviceaccount:...) is exactly the identity a bound token carries.
Runtime tooling like Falco can also alert on the tell-tale behaviour — a process reading the token file that isn’t the application, or an unexpected outbound connection to the API server. For the full runtime-detection picture, see the Kubernetes security best practices guide.
Practice on a Real Cluster
Reading about token binding is not the same as proving, on a live cluster, that a Pod’s token is gone, that a ServiceAccount can’t read Secrets, and that a legacy Secret has been found and removed. The CKS is a hands-on, performance-based exam — you will be editing manifests and running kubectl auth can-i against the clock, not answering multiple-choice questions.
The fastest way to make these moves automatic is to break and repair a real cluster repeatedly. Sailor.sh’s Certified Kubernetes Security Specialist (CKS) Mock Exam Bundle gives you full-length, browser-based performance exams against live clusters, with the same style of tasks — disabling token automount, tightening ServiceAccount RBAC, and hunting non-expiring credentials — so the workflow is muscle memory by exam day.
If you’d rather start free and local, the CKS practice environment guide walks through building a lab with kind or minikube. The official Kubernetes ServiceAccount documentation and the managing ServiceAccounts task page are allowed during the exam — practise navigating them quickly.
Frequently Asked Questions
What is the difference between a bound token and a legacy ServiceAccount token?
A legacy token is stored in a kubernetes.io/service-account-token Secret, never expires, and has no audience binding — steal it once and it works forever. A bound token is minted on demand by the TokenRequest API, is short-lived (default ~1 hour with automatic kubelet rotation), is bound to a specific audience and to the Pod object, and is invalidated when the Pod is deleted. Since Kubernetes 1.24, Pods receive bound tokens by default and ServiceAccounts no longer auto-generate a Secret.
How do I stop Kubernetes from mounting a ServiceAccount token into a Pod?
Set automountServiceAccountToken: false. On the ServiceAccount it becomes the default for all Pods using that SA; on the Pod spec it applies to that Pod and overrides the ServiceAccount-level setting. Verify with kubectl exec <pod> -- ls /var/run/secrets/kubernetes.io/serviceaccount/ — the directory should not exist.
Does setting automountServiceAccountToken on the ServiceAccount override the Pod setting?
No — it is the other way round. The Pod-level automountServiceAccountToken always wins over the ServiceAccount-level value. A Pod can opt back in to a token even if its ServiceAccount opted out, and vice versa. This precedence is a common exam trap.
How do I create a short-lived token for a ServiceAccount?
Use kubectl create token <sa> --duration=<duration>. It calls the TokenRequest API and returns a bound, expiring token without creating any Secret. Add --audience=<name> to bind it to a specific downstream service so it cannot be used against the Kubernetes API.
How do I find non-expiring (legacy) ServiceAccount tokens in a cluster?
Run kubectl get secrets -A --field-selector type=kubernetes.io/service-account-token. Any results are Secret-based tokens that do not expire. Decide whether each is genuinely required; if not, delete it. Prefer on-demand kubectl create token or a projected token volume with a short expirationSeconds instead of static Secrets.
What can an attacker do with a stolen ServiceAccount token?
Exactly what the ServiceAccount’s RBAC allows — no more, no less. The attacker reads /var/run/secrets/kubernetes.io/serviceaccount/token from a compromised Pod and replays it against the API server. If the SA can list Secrets, they harvest credentials; if it is bound to cluster-admin, they own the cluster. The defences are least-privilege RBAC (so the token can’t do much), disabling automount (so there’s no token to steal), and short-lived bound tokens (so a stolen one expires quickly).
Why does my Pod get 403 Forbidden when calling the API?
The token authenticated successfully (the API server knows who you are), but the ServiceAccount has no RBAC permission for that action (it doesn’t know what you may do). Check with kubectl auth can-i <verb> <resource> --as=system:serviceaccount:<ns>:<sa> and grant a minimal Role/RoleBinding if the access is legitimately needed. From a security standpoint, a 403 on an over-broad request is the system working as intended.