Certified Kubernetes Application Developer (CKAD) #14 ServiceAccount and RBAC (App Perspective)

In #13 ConfigMap and Secret in Depth you learned how to inject configuration and secret values into an app. But sometimes an app doesn’t just receive values — it needs to call the Kubernetes API directly from inside the Pod. That’s the case for apps that read other resources in a controller pattern, list the Pods in their own namespace, or update a ConfigMap.

What determines “under whose authority does that call happen” is the ServiceAccount (SA) and RBAC. If the identity a person uses when operating a cluster with kubectl is a user account, then the identity an in-Pod app uses when calling the API is a ServiceAccount. CKAD doesn’t probe RBAC as deeply as CKA, the operator exam, but attaching the right SA to an app and granting it least privilege is squarely in scope. This post focuses on that app perspective.

The picture of an in-Pod app calling the API #

Let’s set the big picture first. When an app calls the Kubernetes API, the API server goes through two stages.

  1. Authentication: who is the identity that sent this request. An in-Pod app proves its identity with a ServiceAccount token.
  2. Authorization: does that identity have permission to perform this action. RBAC (Role-Based Access Control) decides, by rules, “which identity can perform which action on which resource”.

The ServiceAccount provides the identity for stage 1, and RBAC defines the permissions for stage 2. The two are connected by a RoleBinding. In other words, the flow is create an SA, define permissions with a Role, and bind the two together with a RoleBinding. Once this flow is in your hands, most exam questions fall into place. For the broader background on Kubernetes authentication and authorization, see K8s Intermediate #7.

ServiceAccount #

The default SA in every namespace #

A ServiceAccount is a namespaced resource. When you create a namespace, a ServiceAccount named default is created automatically. If you don’t specify serviceAccountName in the Pod spec, the Pod runs as the default SA of its own namespace.

# List the SAs in the current namespace
k get serviceaccount
k get sa            # short form

# Details of the default SA
k get sa default -o yaml

The default SA has no permissions granted to it. So when an app running as the default SA calls the API, it’s mostly rejected with Forbidden. For an app that needs permissions, the standard practice is to create a dedicated SA and grant it only least privilege.

Creating an SA #

You create a dedicated SA imperatively in one line.

# Create an SA named app-sa
k create serviceaccount app-sa

# Pull just the manifest skeleton with dry-run
k create sa app-sa $do > sa.yaml

The generated manifest is a very simple form: kind: ServiceAccount holding just a name and namespace.

Attaching an SA to a Pod #

Which SA a Pod runs as is specified by serviceAccountName in the spec.

apiVersion: v1
kind: Pod
metadata:
  name: api-client
spec:
  serviceAccountName: app-sa
  containers:
    - name: app
      image: bitnami/kubectl
      command: ["sleep", "3600"]

When you need to change the SA of a running Deployment, edit the spec directly or use the set command.

# Swap the SA of a Deployment
k set serviceaccount deployment web app-sa

serviceAccountName is applied only at Pod creation time, so to change an existing Pod’s SA you have to recreate the Pod. For a Deployment, the command above triggers a rollout to a new ReplicaSet, which swaps it automatically.

Token mounting #

The auto-mounted SA token #

When a Pod comes up, Kubernetes automatically mounts that SA’s token at a fixed path inside the container.

/var/run/secrets/kubernetes.io/serviceaccount/
├── token       # the SA's JWT token
├── ca.crt      # CA certificate for verifying the API server
└── namespace   # the name of the namespace the Pod belongs to

The app or client library (in-cluster config) reads the token at this path to authenticate to the API server. If you check directly inside the container, you can see the token is mounted.

k exec api-client -- cat /var/run/secrets/kubernetes.io/serviceaccount/token
k exec api-client -- ls /var/run/secrets/kubernetes.io/serviceaccount/

Turning off auto-mount #

Mounting a token even into apps that never call the API is unnecessary exposure. Following the principle of least privilege, it’s safer to turn off auto-mount for Pods that don’t need a token. You turn it off with automountServiceAccountToken: false.

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

This setting can also live on the ServiceAccount side. Set on the SA, it applies by default to every Pod that uses that SA, and the Pod spec’s setting takes precedence over the SA setting.

apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
automountServiceAccountToken: false

Projected token: audience and expiration #

Modern Kubernetes injects the auto-mounted token as a projected volume. This approach lets you specify an expiration and an audience for the token, making it safer than a token valid indefinitely. The cluster puts the auto-mounted default token in this form on its own, but if you need a specific audience or expiration time, you declare it explicitly as a projected volume.

apiVersion: v1
kind: Pod
metadata:
  name: projected-token
spec:
  serviceAccountName: app-sa
  containers:
    - name: app
      image: nginx
      volumeMounts:
        - name: token
          mountPath: /var/run/secrets/tokens
          readOnly: true
  volumes:
    - name: token
      projected:
        sources:
          - serviceAccountToken:
              path: token
              audience: my-api          # identifier of the token's intended recipient
              expirationSeconds: 3600   # expires after 1 hour

expirationSeconds limits the token’s lifetime, and audience pins down which service the token is meant for. As expiration nears, the kubelet automatically refreshes the token and re-mounts it.

RBAC: granting permissions to an app #

Attaching an SA alone grants no permissions. Just like the default SA, a newly created SA can do nothing at first. To grant permissions you have to define permissions with a Role and connect those permissions to the SA with a RoleBinding.

Role: a namespace-scoped bundle of permissions #

A Role defines “which actions (verbs) are allowed on which resources” within a single namespace. The actions are API verbs like get, list, watch, create, update, patch, delete.

# Create a Role with get/list/watch permission on pods
k create role pod-reader \
  --verb=get,list,watch \
  --resource=pods

# Check the manifest with dry-run
k create role pod-reader --verb=get,list,watch --resource=pods $do

The generated Role manifest carries three things under rules: apiGroups, resources, and verbs. A frequently missed point is that apiGroups for the core API group (pod, service, configmap, and so on) is the empty string "". We’ll see the full form in the combined example below.

RoleBinding: connecting a Role to an SA #

A RoleBinding grants the permissions a Role defines to a specific subject. The subject can be a user, a group, or a ServiceAccount. From the app perspective, the SA is the subject.

# Grant the pod-reader Role to the app-sa SA in the dev namespace
k create rolebinding app-sa-pod-reader \
  --role=pod-reader \
  --serviceaccount=dev:app-sa

The --serviceaccount value is in the form namespace:SA-name. The generated RoleBinding consists of two parts: roleRef (which permissions to grant) and subjects (who to grant them to). Now a Pod running as app-sa can list Pods in the dev namespace. We’ll look at the completed form together in the combined example below.

Verifying permissions: auth can-i #

Whether the permissions are properly attached is checked with kubectl auth can-i. The key is checking by impersonating a specific identity with the --as flag. The identity string for an SA is in the form system:serviceaccount:namespace:SA-name.

# Can app-sa get pods in dev?
k auth can-i get pods \
  --as=system:serviceaccount:dev:app-sa \
  -n dev
# -> yes

# Can app-sa delete pods? (not in the Role)
k auth can-i delete pods \
  --as=system:serviceaccount:dev:app-sa \
  -n dev
# -> no

The answer comes back instantly as yes or no, making it the fastest way to double-check after solving an RBAC question. If you grant a permission in the exam, building the habit of confirming once with this command is the safe move.

ClusterRole and ClusterRoleBinding #

Whereas Role and RoleBinding are valid only within a single namespace, ClusterRole and ClusterRoleBinding are cluster-wide in scope. You use them for resources that don’t belong to a namespace (like nodes), or when you need permissions spanning multiple namespaces. From the app perspective, namespace scope is enough most of the time, so use Role and RoleBinding first; the detailed use of ClusterRole is covered more deeply in the CKA area, the operator exam.

Full example: SA + Role + RoleBinding + Pod #

Combining the pieces so far into one gives a completed manifest that grants an app running as a dedicated SA only the minimal Pod-list permission.

# ServiceAccount: the app's dedicated identity
apiVersion: v1
kind: ServiceAccount
metadata:
  name: app-sa
  namespace: dev
---
# Role: permission to read pods in the dev namespace
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-reader
  namespace: dev
rules:
  - apiGroups: [""]
    resources: ["pods"]
    verbs: ["get", "list", "watch"]
---
# RoleBinding: grant the pod-reader permission to app-sa
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: app-sa-pod-reader
  namespace: dev
roleRef:
  apiGroup: rbac.authorization.k8s.io
  kind: Role
  name: pod-reader
subjects:
  - kind: ServiceAccount
    name: app-sa
    namespace: dev
---
# Pod: the app running as app-sa
apiVersion: v1
kind: Pod
metadata:
  name: api-client
  namespace: dev
spec:
  serviceAccountName: app-sa
  containers:
    - name: app
      image: bitnami/kubectl
      command: ["sleep", "3600"]

After applying it, let’s verify permissions directly from inside the Pod. Because the SA token is auto-mounted, the kubectl binary inside the container authenticates with that token automatically.

k apply -f app-rbac.yaml

# The most trustworthy verification that permissions are attached: call from inside the actual Pod
k exec -n dev api-client -- kubectl get pods
# -> success if the list of Pods in the dev namespace is printed

k exec -n dev api-client -- kubectl get secrets
# -> Forbidden. Rejected because the Role has no secret permission

As the last two commands show, it works only within the granted permissions and everything else is rejected. This is what the principle of least privilege looks like in action.

Exam points #

  • An SA is a namespaced resource. If unspecified, the Pod runs as the default SA, and the default SA has no permissions.
  • The field for attaching an SA to a Pod is spec.serviceAccountName. For a running Deployment, swap it with k set serviceaccount deploy <name> <sa>.
  • The SA token is auto-mounted at /var/run/secrets/kubernetes.io/serviceaccount/. If you don’t need it, turn it off with automountServiceAccountToken: false (the Pod spec takes precedence over the SA setting).
  • Granting permissions is two steps: Role (define permissions) + RoleBinding (connect to the SA). Getting k create role and k create rolebinding --serviceaccount=ns:sa into your hands as one-liners saves time.
  • The double-check is k auth can-i <verb> <resource> --as=system:serviceaccount:<ns>:<sa> -n <ns>. It confirms instantly with yes/no.
  • ClusterRole and ClusterRoleBinding are cluster-scoped. From the app perspective, use Role and RoleBinding first.
  • In a Role’s rules, the apiGroups for the core API group (pod, service, configmap, and so on) is the empty string "". People get this wrong often.

Wrap-up #

What this post locked in:

  • ServiceAccount is the identity an in-Pod app uses when calling the API. The standard practice is to create a dedicated SA and grant it only least privilege.
  • Token auto-mount is controlled with automountServiceAccountToken, and a projected token lets you specify an audience and expiration time.
  • RBAC is the two steps of defining permissions with a Role and connecting them to the SA with a RoleBinding.
  • Verification is confirmed instantly with k auth can-i ... --as=system:serviceaccount:....
  • Always grant only as much permission as needed. This, together with the container privilege restriction in the next post, forms the two axes of app security.

Next: SecurityContext and Capabilities #

This post used RBAC to restrict what an app can do toward the API server. The next post shifts the gaze one level inward, restricting what a container can do toward the node and kernel it runs on.

#15 SecurityContext and Capabilities will lay out, by building them directly, how to run a container as non-root with runAsUser and runAsNonRoot, how to align volume ownership with fsGroup, how to lock the root filesystem read-only with readOnlyRootFilesystem, and how to fine-tune Linux capabilities precisely with add/drop.

X