Certified Kubernetes Security Specialist (CKS) #5: ServiceAccount token management, restricting API access, cluster upgrades

If #4 RBAC least privilege in depth narrowed down who can do what with RBAC, this post is about managing the ServiceAccount token — the actual object that carries those permissions around. If a single token that is automatically injected into a Pod is stolen, then no matter how tightly RBAC has scoped permissions, exactly that scoped set of permissions passes straight into the attacker’s hands. That’s why the Cluster Hardening domain starts with not mounting unnecessary tokens in the first place.

In this post we’ll lay out the second half of Cluster Hardening: how to turn off ServiceAccount token mounting, the expiration and audience of bound tokens, blocking anonymous authentication and protecting the kubelet API, and cluster upgrades for applying security patches.

What is a ServiceAccount token #

Every Pod needs an identity to prove itself to the Kubernetes API. That identity is the ServiceAccount (SA), and the SA’s credential is the token. The token is a JWT-format string; kube-apiserver verifies it to determine “this request was sent by the build-bot SA in the default namespace,” and then allows or denies the request based on the RBAC permissions bound to that SA.

The problem is that Kubernetes by default automatically mounts that namespace’s default SA token into every Pod. Inside the Pod, the token lives at these paths.

/var/run/secrets/kubernetes.io/serviceaccount/token
/var/run/secrets/kubernetes.io/serviceaccount/ca.crt
/var/run/secrets/kubernetes.io/serviceaccount/namespace

Most application Pods have no reason to call the Kubernetes API directly. Yet if the token is mounted, then when that Pod is compromised the attacker can take the token as-is and reach the API server. In other words, a token that isn’t even used widens the attack surface.

Blocking the mount with automountServiceAccountToken #

When a container is compromised, an attacker can read the token sitting in plaintext on the file system, call the API server, and manipulate the cluster up to the limit of that SA’s permissions. If scoping permissions down with RBAC is the first line of defense, then not injecting the token at all is a more fundamental block. The setting that turns off the mount is automountServiceAccountToken: false, and it can live in two places.

Turning it off at the Pod level #

To turn off the mount only for a specific Pod, put it directly in the Pod spec.

apiVersion: v1
kind: Pod
metadata:
  name: no-token-pod
  namespace: default
spec:
  automountServiceAccountToken: false
  containers:
    - name: app
      image: nginx:1.27

Inside this Pod, the /var/run/secrets/kubernetes.io/serviceaccount/ path is empty. For an application that doesn’t use the API, leaving it this way is the safe choice.

Turning it off at the ServiceAccount level #

To turn it off in bulk for every Pod using the same SA, put it on the ServiceAccount object.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: restricted-sa
  namespace: default
automountServiceAccountToken: false

Pods using this SA get no token mounted even without specifying it themselves. However, the Pod-level setting overrides the SA-level setting. That is, even if you set false on the SA, setting true on a specific Pod mounts the token on that Pod. Conversely, even if the SA is true (the default), setting false on the Pod means no mount on that Pod.

Setting locationValueResult
PodfalseNever mounted (SA setting ignored)
PodtrueAlways mounted (SA setting ignored)
Pod unset + SAfalseNot mounted
Pod unset + SAdefaultMounted

On the exam, tasks like “make sure no ServiceAccount token is mounted on this Pod” or “turn off automatic token mounting for workloads using this SA” are regulars. The key is to read the wording precisely to tell which level it’s asking for, recall the priority, and apply the setting in the right place.

Creating a dedicated SA and wiring it explicitly #

Instead of using the default SA as-is, the recommended pattern is to create a dedicated SA per workload and grant only the minimum permissions it needs via RBAC. For a Pod that has to call the API, leave the token on but isolate it with a dedicated SA; for a Pod that doesn’t, turn it off.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: build-bot
  namespace: ci
automountServiceAccountToken: true
---
apiVersion: v1
kind: Pod
metadata:
  name: builder
  namespace: ci
spec:
  serviceAccountName: build-bot
  containers:
    - name: builder
      image: gcr.io/kaniko-project/executor:latest

If you don’t specify serviceAccountName, the default SA gets attached, so it’s a good habit to name a dedicated SA explicitly the more a Pod needs permissions.

Bound ServiceAccount tokens #

You also need to know the token types. Before Kubernetes 1.24, creating a ServiceAccount automatically generated a corresponding Secret that held a permanent, non-expiring token. This is the legacy token, and since it never expires, it stays valid indefinitely once leaked — not good from a security standpoint.

Projected tokens (bound tokens) #

From 1.24, when a Pod comes up, the kubelet issues a token via a projected volume. This token is the bound ServiceAccount token, and it has the following characteristics.

  • It has an expiration. By default the kubelet automatically renews the token on an hourly basis and re-mounts it.
  • An audience is bound to it. The token states which recipient (the API server, etc.) it’s for, so it can’t be reused somewhere it doesn’t belong.
  • It’s bound to that Pod’s lifetime. When the Pod is gone, the token becomes invalid.

In other words, even if the token is stolen, the short expiration and audience constraint greatly reduce the blast radius and the window of time. You can also specify a projected token directly in the Pod spec.

apiVersion: v1
kind: Pod
metadata:
  name: api-client
spec:
  serviceAccountName: build-bot
  containers:
    - name: app
      image: nginx:1.27
      volumeMounts:
        - name: token
          mountPath: /var/run/secrets/tokens
          readOnly: true
  volumes:
    - name: token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              expirationSeconds: 3600
              audience: vault

You can shorten the expiration further with expirationSeconds, and with audience you can bind the token to be valid only for a specific recipient.

Difference from legacy Secret tokens #

AspectLegacy Secret tokenBound (projected) token
Issue locationSecret object bound to the SAPod’s projected volume
ExpirationNone (permanent)Yes (default 1 hour, auto-renewed)
audienceNoneCan be specified
LifetimeAs long as the SA/Secret livesBound to the Pod’s lifetime
RecommendedNot recommended (special purposes only)Recommended by default

When you still need a non-expiring token (integrating with external systems, etc.), you can explicitly create a Secret like the following, but from a CKS standpoint the right answer is to use it only when truly necessary and to use bound tokens the rest of the time.

apiVersion: v1
kind: Secret
metadata:
  name: build-bot-token
  namespace: ci
  annotations:
    kubernetes.io/service-account.name: build-bot
type: kubernetes.io/service-account-token

A Secret created this way is filled with a non-expiring token, so if it leaks the risk is high. When the exam gives a task like “create a non-expiring token,” recall this form — but remember alongside it that it’s not recommended as a security best practice.

Restricting API access #

Once you’ve narrowed the tokens, the next step is narrowing the entry into the API server itself. Another pillar of Cluster Hardening is blocking unauthenticated access and excessive exposure.

Turning off anonymous authentication #

By default, kube-apiserver accepts anonymous requests as the system:anonymous user. If RBAC is set up well, there’s almost nothing an anonymous user can do, but turning off anonymous authentication itself is recommended as a way to shrink the attack surface. Put the following flag in the API server manifest.

# /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
  containers:
    - command:
        - kube-apiserver
        - --anonymous-auth=false
        # ... remaining flags

When you modify the static Pod manifest in /etc/kubernetes/manifests/, the kubelet automatically restarts the API server Pod. The API server goes down briefly right after the edit, so getting the indentation and quotes exactly right matters. A single wrong character and the API server won’t come back up.

That said, setting --anonymous-auth=false can affect some health check paths (/healthz, /livez, /readyz), so on the exam it’s safer to turn it off only within the scope the task requires and to check the impact.

Narrowing the entry with RBAC #

The RBAC covered in #4 is the core of restricting API access. Even a request that passes authentication is denied at the authorization stage if it has no permission. Check the following.

  • Inspect whether unnecessary RoleBindings/ClusterRoleBindings are bound to the system:anonymous and system:unauthenticated groups.
  • Reduce broad cluster-admin being attached to multiple SAs.
  • Narrow wildcard (*) verbs and resources down to specific permissions.

Protecting the kubelet API #

As important as the API server is each node’s kubelet API. The kubelet is the component that actually runs containers on a node, and if its API is open, even listing Pods or executing commands inside a container becomes possible — extremely dangerous. The two items CKS frequently checks are the following.

# /var/lib/kubelet/config.yaml
readOnlyPort: 0
authentication:
  anonymous:
    enabled: false
  webhook:
    enabled: true
authorization:
  mode: Webhook
  • readOnlyPort: 0 closes the read-only port 10255 that used to be open without authentication. If this port is open, anyone can read the node’s Pod information and metrics without authentication.
  • anonymous.enabled: false blocks anonymous requests to the kubelet.
  • authorization.mode: Webhook delegates kubelet API requests to the API server’s authorization, so that even an authenticated request gets its permission re-verified.

After changing the configuration, restart the kubelet with systemctl restart kubelet to apply it. These three items are also checked by the CIS benchmark (kube-bench in #3), so it’s good to remember them as a bundle.

Applying security patches via cluster upgrades #

The last pillar is keeping the cluster on the latest version. CVEs (known vulnerabilities) are reported in Kubernetes regularly, and patches are shipped as new minor/patch versions. Running an old version leaves you directly exposed to already-published vulnerabilities, so the upgrade itself is a security task.

Why is upgrading a security measure #

  • Known vulnerabilities in components (kube-apiserver, kubelet, etcd, etc.) get patched.
  • Improvements to security features (e.g., bound tokens, PSA) arrive in new versions.
  • End-of-life (EOL) versions no longer receive security patches, so you have to stay on a version within the support window.

Summary of the kubeadm upgrade procedure #

The actual upgrade procedure is CKA territory, so here we’ll only hit the essentials. The detailed steps are covered in CKA #6 Cluster upgrades.

# 1) Upgrade kubeadm on the control plane node
apt-get update && apt-get install -y kubeadm=1.31.x-*
kubeadm upgrade plan
kubeadm upgrade apply v1.31.x

# 2) Drain that node and upgrade kubelet/kubectl
kubectl drain <node> --ignore-daemonsets
apt-get install -y kubelet=1.31.x-* kubectl=1.31.x-*
systemctl daemon-reload && systemctl restart kubelet
kubectl uncordon <node>

# 3) For worker nodes, run kubeadm upgrade node, then refresh kubelet the same way

The key points are that you go up one minor version at a time, and the ordering of upgrading the control plane first and then the workers. From a CKS standpoint, it’s enough to understand the context that the motivation for the upgrade is applying security patches and responding to CVEs.

Exam points #

  • automountServiceAccountToken: false is the top regular of this domain. It can live in two places, the Pod level and the SA level, and memorize the priority that the Pod level overrides the SA level.
  • Turn the token off for Pods that don’t use the API, and isolate Pods that do with a dedicated SA + minimal RBAC. Don’t attach permissions to the default SA.
  • Bound (projected) tokens are characterized by expiration, audience, and being bound to the Pod’s lifetime. Legacy Secret tokens are not recommended because they never expire; create them as a kubernetes.io/service-account-token Secret only when truly necessary.
  • Narrow the entry with --anonymous-auth=false on the API server and readOnlyPort: 0 , anonymous.enabled: false , authorization.mode: Webhook on the kubelet.
  • After modifying a static Pod manifest (/etc/kubernetes/manifests/) or the kubelet config, verify the restart and that it took effect. Components failing to come up due to an indentation error is a common accident.
  • A cluster upgrade is a security task for responding to CVEs. Remember the ordering: one minor version at a time, control plane first, workers later.

Wrap-up #

What this post locked in:

  • A ServiceAccount token is a Pod’s identity, and by default it’s auto-mounted into every Pod, widening the attack surface.
  • Block unused tokens with automountServiceAccountToken: false. The Pod level takes priority over the SA level.
  • Bound tokens reduce theft damage through expiration, audience, and Pod binding. Legacy Secret tokens never expire, so use them only for special purposes.
  • Narrow API access by blocking anonymous authentication, minimizing RBAC, and protecting the kubelet API (readOnlyPort: 0 and the rest).
  • A cluster upgrade is a security task that applies CVE patches. Control plane first, one minor version at a time.

If you want to revisit the basics of ServiceAccounts and Pod settings from a CKAD standpoint, see CKAD #14 ServiceAccount and security context; for the full steps of the upgrade procedure, CKA #6 Cluster upgrades is a good companion read.

Next — AppArmor profiles #

Now that we’ve finished through Cluster Hardening, we drop down to the node’s Linux kernel level. The domain that confines what a container can do on the host at the operating-system level is System Hardening.

In #6 AppArmor profiles (System Hardening), we’ll work through how AppArmor restricts file access and capabilities, how to author a profile and load it onto a node, and the pattern of attaching that profile to a Pod to confine the container’s behavior — building it ourselves as we go.

X