We closed the previous lesson with an uncomfortable observation: we have given the Rutas Norte components their configuration, their credentials and their resources, but we have not given them an identity. Right now, the pods of web-store, bookings-api, bookings-postgres, redis-cache and notifications-worker are all using the same default ServiceAccount of their namespace, with a Kubernetes API access token mounted inside that none of them needs and that is the first thing an attacker who managed to run code in one of those containers would go looking for. This lesson fixes it: you will understand what a ServiceAccount is and how it differs from a user, how modern projected tokens work, why automountServiceAccountToken: false should be your default choice, and you will talk to the API from inside a pod with curl to see with your own eyes both an authorised response and a well-deserved 403 Forbidden.

Contents

  1. The identity of a workload
  2. Users versus ServiceAccounts
  3. The default ServiceAccount and why it must not be used
  4. Creating dedicated ServiceAccounts for Rutas Norte
  5. The projected token: how it has changed
  6. What lives in /var/run/secrets/kubernetes.io/serviceaccount/
  7. automountServiceAccountToken: false
  8. Talking to the API from inside a pod
  9. The 403 Forbidden and what it means
  10. imagePullSecrets on the ServiceAccount
  11. The ServiceAccount says who you are, RBAC says what you can do
  12. Identity federation with the clouds

  1. The identity of a workload

Every request reaching the kube-apiserver goes through three phases you already know from module 1:

flowchart LR
    A["Request"] --> B["AUTHENTICATION<br/>Who are you?"]
    B --> C["AUTHORIZATION (RBAC)<br/>Can you do this?"]
    C --> D["ADMISSION<br/>LimitRanger, ResourceQuota..."]
    D --> E["etcd"]
    B -.->|"not identified"| F["401 Unauthorized"]
    C -.->|"identified but not allowed"| G["403 Forbidden"]

When you run kubectl get pods, the first phase is resolved with the certificate or the token in your kubeconfig. But what about when the one asking is a pod?

The cases where a pod needs to talk to the API are more common than they seem:

Who What for
An Ingress controller Reading Ingress and Service objects to configure its routing
Prometheus Discovering which pods to scrape (07-03)
cert-manager Creating and updating TLS Secrets (04-05)
An operator Reconciling its custom resources (06-07)
Argo CD Applying manifests from Git (10-05)
An application of yours Reading a ConfigMap live, querying its own replicas, coordinating a leader election

And in Rutas Norte there is a specific case coming in module 6: occupancy-reports, the nightly task, will have to query the API to find out how many bookings-api replicas were active each hour and correlate that with occupancy. It needs an identity and it needs permissions.

But the main reason for studying this is not the pods that do talk to the API: it is the five that do not and yet carry a valid credential around without needing it. That is free attack surface.

  1. Users versus ServiceAccounts

Kubernetes distinguishes two kinds of identity, and the difference is fundamental:

Users ServiceAccounts
For whom People: administrators, developers Processes: pods, controllers
Is it a Kubernetes object? No. There is no kind: User Yes: kind: ServiceAccount
Who manages them An external system: certificates, OIDC, LDAP, cloud IAM Kubernetes, with kubectl create sa
kubectl get Impossible kubectl get serviceaccounts
Scope The whole cluster Namespaced
Name in RBAC [email protected] system:serviceaccount:<ns>:<name>
How they authenticate Client certificate, OIDC token JWT signed by the cluster
Life cycle External Tied to the namespace

The first point always comes as a surprise: Kubernetes has no users. There is no user database, and no command to create one. When your kubeconfig uses a client certificate, Kubernetes reads the CN (Common Name) field of the certificate and takes it as the user name, trusting that the cluster's certificate authority only signs legitimate certificates. With OIDC, it delegates to the identity provider. Managing people is, deliberately, somebody else's problem.

ServiceAccounts, by contrast, are first-class objects:

kubectl get serviceaccounts -n rutas-norte-pro
NAME      SECRETS   AGE
default   0         14d

There is only one, the default, and all our pods are using it. Notice the SECRETS 0: it is the sign that we are on modern Kubernetes, where ServiceAccounts no longer carry an associated Secret with a permanent token. We will come back to that in section 5.

The full name of a ServiceAccount in the authorization system has this shape:

system:serviceaccount:rutas-norte-pro:bookings-api
     |         |              |             |
   prefix    type         namespace       name

That is the exact identifier you will use in the RoleBindings of RBAC, and also the one that appears in the audit logs. In addition, every ServiceAccount automatically belongs to the group system:serviceaccounts and to its namespace's group, system:serviceaccounts:rutas-norte-pro, which lets you grant permissions to all of an environment's accounts at once (something that is almost never a good idea, but worth knowing exists).

  1. The default ServiceAccount and why it must not be used

Every namespace gets a ServiceAccount called default the moment it is created. If a pod specifies none, it is assigned that one.

kubectl get pod bookings-api-7f4b8c9d6-2xkqp -n rutas-norte-pro \
  -o jsonpath='{.spec.serviceAccountName}'; echo
default

Five reasons not to use it:

Reason 1: it is shared. Every pod in the namespace has the same identity. If tomorrow occupancy-reports needs permission to read Deployments and you grant it to default, you are also granting it to web-store, to redis-cache and to bookings-postgres. It is the opposite of least privilege.

Reason 2: it destroys traceability. In the audit log every request shows up as system:serviceaccount:rutas-norte-pro:default. Faced with a suspicious access, there is no way of knowing which component made it.

Reason 3: it prevents selective revocation. If notifications-worker is compromised, you want to withdraw its permissions immediately. With the default, withdrawing them means withdrawing them from the whole namespace.

Reason 4: it mounts a token almost nobody needs. By default, automountServiceAccountToken is true, so all our pods carry a valid API token inside. Check it:

kubectl exec -n rutas-norte-pro deploy/web-store -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ca.crt
namespace
token

An nginx server that only serves static files has a cluster API access credential sitting there. If someone finds an arbitrary file read vulnerability in that application (hardly exotic), the first thing they will do is read that token.

Reason 5: the temptation to grant it permissions. It is so convenient that, in a rush, somebody ends up binding the default to a powerful ClusterRole "just to make it work". And that never gets reverted.

The rule is simple:

One ServiceAccount per component, and automountServiceAccountToken: false on all of them except those that genuinely talk to the API.

  1. Creating dedicated ServiceAccounts for Rutas Norte

Let us start with the object. It is one of the simplest in Kubernetes:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
  labels:
    app: bookings-api
    app.kubernetes.io/name: bookings-api
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
automountServiceAccountToken: false     # bookings-api does NOT talk to the API

Or imperatively, to generate the YAML:

kubectl create serviceaccount bookings-api -n rutas-norte-pro --dry-run=client -o yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  creationTimestamp: null
  name: bookings-api
  namespace: rutas-norte-pro

The complete plan for Rutas Norte, with the reasoned decision for each component:

Component ServiceAccount Talks to the API? automountServiceAccountToken
web-store web-store No false
bookings-api bookings-api No false
bookings-postgres bookings-postgres No false
redis-cache redis-cache No false
notifications-worker notifications-worker No false
occupancy-reports occupancy-reports Yes (module 6) true

Five out of six need no token. That is the normal outcome: the vast majority of business applications do not talk to the Kubernetes API. And even so, the default configuration mounts one for all of them.

So why create a ServiceAccount for those that will not use it, if they are not going to talk to the API? For three reasons:

  1. Traceability: if one day one of them makes a request, you will know which one.
  2. imagePullSecrets: the ServiceAccount is the right place to declare them (section 10).
  3. Preparation: when tomorrow bookings-api needs a specific permission, the identity already exists and there is no need to touch the Deployment.

The six objects, in k8s/base/serviceaccounts.yaml:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: web-store
  namespace: rutas-norte-pro
  labels: {app: web-store, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
  labels: {app: bookings-api, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-postgres
  namespace: rutas-norte-pro
  labels: {app: bookings-postgres, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: redis-cache
  namespace: rutas-norte-pro
  labels: {app: redis-cache, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: notifications-worker
  namespace: rutas-norte-pro
  labels: {app: notifications-worker, app.kubernetes.io/part-of: rutas-norte, environment: pro}
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte
---
apiVersion: v1
kind: ServiceAccount
metadata:
  name: occupancy-reports
  namespace: rutas-norte-pro
  labels: {app: occupancy-reports, app.kubernetes.io/part-of: rutas-norte, environment: pro}
# This one DOES need the token: it will query the API in module 6.
automountServiceAccountToken: true
imagePullSecrets:
  - name: registry-rutasnorte

And the assignment on the pod, with the serviceAccountName field:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
spec:
  replicas: 4
  selector:
    matchLabels:
      app: bookings-api
      environment: pro
  template:
    metadata:
      labels:
        app: bookings-api
        app.kubernetes.io/name: bookings-api
        app.kubernetes.io/part-of: rutas-norte
        environment: pro
    spec:
      serviceAccountName: bookings-api       # <-- the pod's identity
      automountServiceAccountToken: false    # belt and braces
      containers:
        - name: api
          image: registry.rutasnorte.example/bookings-api:2.5.0

A historical detail you will see in old manifests: there is also a serviceAccount field (without Name). It has been deprecated for many versions and is kept only for compatibility. Always use serviceAccountName.

We apply and verify:

kubectl apply -f k8s/base/serviceaccounts.yaml
kubectl rollout restart deploy -n rutas-norte-pro
kubectl get pods -n rutas-norte-pro \
  -o custom-columns='POD:.metadata.name,SA:.spec.serviceAccountName'
serviceaccount/web-store created
serviceaccount/bookings-api created
serviceaccount/bookings-postgres created
serviceaccount/redis-cache created
serviceaccount/notifications-worker created
serviceaccount/occupancy-reports created

POD                                     SA
bookings-api-8b5c7d9e2-4mkqp            bookings-api
bookings-api-8b5c7d9e2-9wnzr            bookings-api
bookings-postgres-6e9g7c0d5-l3nqx       bookings-postgres
redis-cache-7d0e9g8c6-wo5ry             redis-cache
web-store-6c8d0g5e9-i8qol               web-store
notifications-worker-7g8e0d9c5-mn4qu    notifications-worker

Each component with its own identity. And the token has disappeared from where it was not needed:

kubectl exec -n rutas-norte-pro deploy/web-store -- 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

That error is exactly the desired outcome. There is no credential to steal.

  1. The projected token: how it has changed

This part has a history and it is important to know it, because you will come across manifests and tutorials from the earlier era.

Before Kubernetes 1.24: permanent tokens in Secrets

When a ServiceAccount was created, a controller automatically generated a Secret of type kubernetes.io/service-account-token with a JWT inside. That token:

  • Never expired.
  • Was not tied to any pod: anyone who copied it could use it from anywhere, even from outside the cluster.
  • Was stored in etcd, so it was in the backups.
  • Could not be revoked without deleting the Secret and the ServiceAccount.

It was, in practice, an eternal password in every namespace. If a token leaked into a log, a dump or a repository, it was still valid months later.

From 1.24, and mandatory since 1.25: the TokenRequest API

The current mechanism is completely different and much better:

sequenceDiagram
    participant K as kubelet
    participant A as apiserver (TokenRequest)
    participant P as Pod

    K->>A: TokenRequest for the SA bookings-api,<br/>limited audience and lifetime,<br/>bound to THIS pod
    A-->>K: signed JWT (expires in 1 hour)
    K->>P: writes it to tmpfs<br/>/var/run/secrets/.../token
    Note over K,P: At 48 minutes (80% of the lifetime)
    K->>A: renewal
    A-->>K: new JWT
    K->>P: replaces the file
    Note over P: The application must RE-READ the file

The properties of the modern token:

Property Old token (Secret) Projected token (TokenRequest)
Expiry Never 1 hour by default
Rotation Manual Automatic, by the kubelet at 80% of the lifetime
Bound to the pod No Yes: it contains the pod UID
When the pod is deleted Still valid Invalidated
Audience (aud) Generic Restrictable to one recipient
Stored in etcd Yes No
Where it lives Secret + volume Only in the pod's tmpfs

Let us look at the real contents of a token. Inside a pod that does have one mounted:

kubectl exec -n rutas-norte-pro deploy/occupancy-reports -- sh -c \
  'cut -d. -f2 /var/run/secrets/kubernetes.io/serviceaccount/token | base64 -d 2>/dev/null'
{
  "aud": ["https://kubernetes.default.svc.cluster.local"],
  "exp": 1785703921,
  "iat": 1785700321,
  "iss": "https://kubernetes.default.svc.cluster.local",
  "jti": "b41e9c02-7f3a-4d18-9a6e-2c85f0d31447",
  "kubernetes.io": {
    "namespace": "rutas-norte-pro",
    "node": {"name": "rutas-norte-m02", "uid": "d3a1..."},
    "pod": {"name": "occupancy-reports-28912440-x7mzq", "uid": "7f2c..."},
    "serviceaccount": {"name": "occupancy-reports", "uid": "1e8b..."}
  },
  "nbf": 1785700321,
  "sub": "system:serviceaccount:rutas-norte-pro:occupancy-reports"
}

It is a standard JWT. What matters:

  • sub is the identifier RBAC will use.
  • exp - iat = 3600 seconds: a one-hour lifetime.
  • kubernetes.io.pod ties the token to a specific pod. If that pod is deleted, the apiserver rejects the token even if it has not expired.
  • aud limits who it is valid for.

An important practical warning: if your application reads the token once at start-up and keeps it in memory, it will stop working in an hour. The official client libraries (client-go, the Python kubernetes client, and so on) re-read the file automatically. If you talk to the API with curl or with a generic HTTP library, you have to re-read the file on every request. It is a source of baffling 401 errors that appear exactly one hour after the deployment.

The projection can be customised with an explicit volume:

      volumes:
        - name: api-token
          projected:
            sources:
              - serviceAccountToken:
                  path: token
                  expirationSeconds: 3600          # minimum 600
                  audience: api.rutasnorte.example  # for one specific recipient

The audience field is especially useful for an advanced pattern: issuing a token that is only valid for one specific external service, so that if it leaks it cannot be used to talk to the Kubernetes API.

And if you ever need a permanent token (integrating an external tool that cannot rotate), it has to be created explicitly:

apiVersion: v1
kind: Secret
metadata:
  name: occupancy-reports-permanent-token
  namespace: rutas-norte-pro
  annotations:
    kubernetes.io/service-account.name: occupancy-reports
type: kubernetes.io/service-account-token

Avoid it whenever you can. It reintroduces every problem of the old model. For one-off cases, the correct alternative is to request a short-lived token:

kubectl create token occupancy-reports -n rutas-norte-pro --duration=30m
eyJhbGciOiJSUzI1NiIsImtpZCI6Ilp3...

  1. What lives in /var/run/secrets/kubernetes.io/serviceaccount/

When the token is mounted, the directory contains three files:

kubectl exec -n rutas-norte-pro deploy/occupancy-reports -- sh -c \
  'ls -la /var/run/secrets/kubernetes.io/serviceaccount/ && mount | grep serviceaccount'
total 0
drwxrwxrwt 3 root root 140 Aug  5 22:03 .
drwxr-xr-x 3 root root  60 Aug  5 22:03 ..
lrwxrwxrwx 1 root root  13 Aug  5 22:03 ca.crt -> ..data/ca.crt
lrwxrwxrwx 1 root root  16 Aug  5 22:03 namespace -> ..data/namespace
lrwxrwxrwx 1 root root  12 Aug  5 22:03 token -> ..data/token

tmpfs on /var/run/secrets/kubernetes.io/serviceaccount type tmpfs (ro,relatime)

You recognise two things from earlier lessons: the chain of links to ..data that makes atomic updates possible (here it is what enables token rotation) and the tmpfs, that is, RAM.

File Contents What it is for
token The signed JWT The Authorization: Bearer <token> header
ca.crt The cluster CA certificate Verifying the apiserver's TLS certificate
namespace The namespace name, in plain text So the application knows where it lives without the Downward API
kubectl exec -n rutas-norte-pro deploy/occupancy-reports -- sh -c \
  'cat /var/run/secrets/kubernetes.io/serviceaccount/namespace; echo; \
   head -1 /var/run/secrets/kubernetes.io/serviceaccount/ca.crt'
rutas-norte-pro
-----BEGIN CERTIFICATE-----

The ca.crt is the piece people usually forget. Without it, a client talking to https://kubernetes.default.svc cannot validate the apiserver's certificate and has to fall back on --insecure, which is exactly what you must not do: with no validation, an attacker with access to the pod's network could impersonate the apiserver and capture the token.

Together with the KUBERNETES_SERVICE_HOST and KUBERNETES_SERVICE_PORT variables that Kubernetes injects into every pod (you saw them in the environment variables lesson), these three files are everything needed to talk to the API. It is precisely what the client libraries call in-cluster configuration (InClusterConfig).

  1. automountServiceAccountToken: false

This field can be set in two places, and the precedence matters:

# On the ServiceAccount: it affects ALL the pods that use it
apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
automountServiceAccountToken: false
# On the pod: it affects only THAT pod and it BEATS the ServiceAccount
spec:
  serviceAccountName: bookings-api
  automountServiceAccountToken: false
ServiceAccount Pod Result
(unspecified) (unspecified) Mounted (the default)
false (unspecified) Not mounted
true (unspecified) Mounted
false true Mounted: the pod wins
true false Not mounted: the pod wins

The pod always has the last word. That enables the recommended pattern: set false on the ServiceAccount as a safety net, and an explicit true only on the specific pod that genuinely needs it.

Why this matters so much, in security terms: if an attacker manages to run code in a container — through an application vulnerability, a compromised dependency or an injection — the first thing an automated exploitation script does is read that token and probe what permissions it has. It is a standard step in any Kubernetes privilege escalation tool.

Let us compare the attack surface:

With the token mounted Without the token mounted
Can read the token Yes It does not exist
Can query the API Yes, with the SA's permissions No, it cannot even identify itself
Can enumerate the cluster Depends on RBAC No
If RBAC is misconfigured Privilege escalation Nothing to escalate

With no token, even a careless RBAC configuration is neutralised for that pod: there is no credential with which to exercise it.

Checking the state across the whole cluster, which is an audit worth doing:

kubectl get pods -A -o json | jq -r '
  .items[] |
  select(.spec.automountServiceAccountToken != false) |
  "\(.metadata.namespace)/\(.metadata.name) -> \(.spec.serviceAccountName)"' | head -10
kube-system/coredns-7db6d8ff4d-2vqxn -> coredns
kube-system/kube-proxy-8xvmk -> kube-proxy
rutas-norte-pro/occupancy-reports-28912440-x7mzq -> occupancy-reports

The system ones legitimately need it. From Rutas Norte, only occupancy-reports. That is the goal.

An operational note: if you add automountServiceAccountToken: false to a ServiceAccount, the pods already running keep their token. The mount is decided when the pod is created. They have to be recreated:

kubectl rollout restart deploy -n rutas-norte-pro

  1. Talking to the API from inside a pod

We are going to do it by hand to understand what the client libraries do internally. We will use occupancy-reports, the only Rutas Norte component that genuinely needs to talk to the API.

Step 1: a pod with the ServiceAccount and the token mounted.

apiVersion: v1
kind: Pod
metadata:
  name: api-client
  namespace: rutas-norte-pro
  labels:
    app: occupancy-reports
    app.kubernetes.io/part-of: rutas-norte
    environment: pro
spec:
  serviceAccountName: occupancy-reports
  automountServiceAccountToken: true
  restartPolicy: Never
  containers:
    - name: client
      image: curlimages/curl:8.10.1
      command: ["sleep", "3600"]
      resources:
        requests:
          cpu: 50m
          memory: 64Mi
        limits:
          cpu: 100m
          memory: 128Mi
kubectl apply -f api-client.yaml
kubectl exec -it api-client -n rutas-norte-pro -- sh

Step 2: prepare the variables inside the pod.

# Already inside the container
SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN=$(cat $SA/token)
NS=$(cat $SA/namespace)
APISERVER=https://kubernetes.default.svc

echo "Namespace: $NS"
echo "API:       $APISERVER"
echo "Token:     ${TOKEN:0:40}..."
Namespace: rutas-norte-pro
API:       https://kubernetes.default.svc
Token:     eyJhbGciOiJSUzI1NiIsImtpZCI6Ilp3RTFvSm...

kubernetes.default.svc is the apiserver's Service, which exists in the default namespace of every cluster. Any pod can resolve it via DNS, as you saw in module 2.

Step 3: the first request, to the version endpoint (which requires no permissions).

curl -s --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/version
{
  "major": "1",
  "minor": "30",
  "gitVersion": "v1.30.4",
  "goVersion": "go1.22.5",
  "platform": "linux/amd64"
}

It worked. The three elements of the request:

  1. --cacert $SA/ca.crt: verifies that the server really is the cluster's apiserver. Never use -k or --insecure.
  2. Authorization: Bearer $TOKEN: the authentication. It is the standard JWT mechanism.
  3. The internal Service URL, resolved by DNS.

Step 4: ask who I am. This endpoint is extremely useful for debugging:

curl -s --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST $APISERVER/apis/authentication.k8s.io/v1/selfsubjectreviews \
  -d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}'
{
  "kind": "SelfSubjectReview",
  "apiVersion": "authentication.k8s.io/v1",
  "status": {
    "userInfo": {
      "username": "system:serviceaccount:rutas-norte-pro:occupancy-reports",
      "uid": "1e8b4c73-9d02-4a5f-8e31-7b6c0f294a15",
      "groups": [
        "system:serviceaccounts",
        "system:serviceaccounts:rutas-norte-pro",
        "system:authenticated"
      ]
    }
  }
}

There is the complete identity: the username in the format we anticipated and the three automatic groups. From outside, the equivalent is:

kubectl auth whoami --as=system:serviceaccount:rutas-norte-pro:occupancy-reports

Step 5: try something that requires permissions.

curl -s -o /dev/null -w "%{http_code}\n" --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NS/pods
403

And here comes the instructive part.

  1. The 403 Forbidden and what it means

Let us look at the full body of that response:

curl -s --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" \
  $APISERVER/api/v1/namespaces/$NS/pods | head -20
{
  "kind": "Status",
  "apiVersion": "v1",
  "metadata": {},
  "status": "Failure",
  "message": "pods is forbidden: User \"system:serviceaccount:rutas-norte-pro:occupancy-reports\"
              cannot list resource \"pods\" in API group \"\" in the namespace \"rutas-norte-pro\"",
  "reason": "Forbidden",
  "details": {"kind": "pods"},
  "code": 403
}

This message is a small lesson in itself. Let us break it down:

Part Meaning
User "system:serviceaccount:..." Authentication worked: the apiserver knows who you are
cannot list The denied verb
resource "pods" The resource
in API group "" The API group: "" is the core group
in the namespace "..." The scope
"code": 403 Forbidden, not "unauthenticated"

The distinction between 401 and 403 is fundamental and must be clear:

Code Meaning Typical cause
401 Unauthorized "I do not know who you are" Token missing, expired, malformed or signed by another cluster
403 Forbidden "I know who you are, but you cannot" A Role or a RoleBinding is missing

If you get a 401, the problem is the token: check you are reading it correctly, that it has not expired (remember the one-hour lifetime) and that the pod has the volume mounted. If you get a 403, the token is fine and what is missing is permissions.

And that 403 is exactly what should happen right now. Our occupancy-reports ServiceAccount has no permissions granted at all, because granting permissions is RBAC's job, and RBAC is the subject of lesson 08-01. A freshly created ServiceAccount, with no RoleBindings, can do absolutely nothing beyond the few public endpoints such as /version.

This is worth underlining because it is the correct design:

Kubernetes denies by default. Creating an identity grants no permissions. Everything a ServiceAccount can do has to be granted explicitly.

We can check permissions without making the request, with kubectl auth can-i:

kubectl auth can-i list pods -n rutas-norte-pro \
  --as=system:serviceaccount:rutas-norte-pro:occupancy-reports
kubectl auth can-i get deployments -n rutas-norte-pro \
  --as=system:serviceaccount:rutas-norte-pro:occupancy-reports
no
no

And the complete list of what it can do:

kubectl auth can-i --list -n rutas-norte-pro \
  --as=system:serviceaccount:rutas-norte-pro:occupancy-reports
Resources                                       Non-Resource URLs   Verbs
selfsubjectreviews.authentication.k8s.io        []                  [create]
selfsubjectaccessreviews.authorization.k8s.io   []                  [create]
selfsubjectrulesreviews.authorization.k8s.io    []                  [create]
                                                [/healthz]          [get]
                                                [/version]          [get]

Only the bare minimum: ask who it is, ask what it can do, and check health and version. No reading pods, no deployments, and certainly not the Secrets holding the bookings-postgres password.

As a preview of module 8, this is what the minimal grant occupancy-reports will need looks like, so you know where we are heading:

# PREVIEW of 08-01: do not apply it yet, it is explained there
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: deployment-reader
  namespace: rutas-norte-pro
rules:
  - apiGroups: ["apps"]
    resources: ["deployments"]
    verbs: ["get", "list"]          # read only, deployments only
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: occupancy-reports-reader
  namespace: rutas-norte-pro
subjects:
  - kind: ServiceAccount
    name: occupancy-reports
    namespace: rutas-norte-pro
roleRef:
  kind: Role
  name: deployment-reader
  apiGroup: rbac.authorization.k8s.io

Look at the subjects: that is where the ServiceAccount we created today connects with the permissions. The two pieces fit together, but they are different objects with different responsibilities.

We clean up the test pod:

kubectl delete pod api-client -n rutas-norte-pro

  1. imagePullSecrets on the ServiceAccount

We pick up a loose end from the Secrets lesson. There we declared imagePullSecrets on each pod so we could pull images from registry.rutasnorte.example:

    spec:
      imagePullSecrets:
        - name: registry-rutasnorte      # it has to be repeated in EVERY pod

Repeating it in every Deployment, every Job and every CronJob is tedious and, above all, easy to forget: the failure shows up weeks later, when somebody adds a new component and runs into an incomprehensible ImagePullBackOff.

The ServiceAccount is the right place:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: bookings-api
  namespace: rutas-norte-pro
automountServiceAccountToken: false
imagePullSecrets:
  - name: registry-rutasnorte

And the Deployment stays clean:

    spec:
      serviceAccountName: bookings-api    # inherits the imagePullSecrets
      containers:
        - name: api
          image: registry.rutasnorte.example/bookings-api:2.5.0

How it works: when a pod is created, an admission controller copies the ServiceAccount's imagePullSecrets into the pod's spec. You can see it:

kubectl get pod bookings-api-8b5c7d9e2-4mkqp -n rutas-norte-pro \
  -o jsonpath='{.spec.imagePullSecrets}'; echo
[{"name":"registry-rutasnorte"}]

The manifest did not have it and the object does. It is the same phenomenon as with the LimitRanger from the previous lesson: an admission controller has mutated the object.

Three details:

  1. They are added, not replaced. If the pod declares its own, they are combined with those of the ServiceAccount.
  2. The Secret must exist in the same namespace. It has to be created in each of the three environments.
  3. It is not retroactive. Pods already created do not inherit it; they have to be recreated.

And a very common trick to avoid repeating it per component: add it to each namespace's default ServiceAccount, so that any pod not specifying another inherits it.

kubectl patch serviceaccount default -n rutas-norte-pro \
  -p '{"imagePullSecrets":[{"name":"registry-rutasnorte"}]}'
serviceaccount/default patched

It is useful, but remember that it does not exempt you from creating dedicated ServiceAccounts: imagePullSecrets is the only reasonable thing the default should carry.

  1. The ServiceAccount says who you are, RBAC says what you can do

This is the sentence that sums up the lesson and it is worth engraving:

The ServiceAccount is the identity; RBAC is the permissions. They are two independent things and both are needed.

flowchart LR
    SA["ServiceAccount<br/>occupancy-reports<br/>WHO you are"] --> T["JWT token<br/>mounted in the pod"]
    T --> AU["Authentication<br/>the apiserver identifies you"]
    AU --> RB["RoleBinding<br/>connects identity and permissions"]
    RB --> R["Role<br/>WHAT you can do"]
    R --> OK["Request allowed"]
    AU -.->|"no RoleBinding"| NO["403 Forbidden"]

The possible combinations and what they mean:

ServiceAccount RBAC Result
Dedicated No permissions It identifies itself, it can do nothing. The current state of Rutas Norte
Dedicated Minimal permissions The goal: each component does exactly its own job
Shared default Broad permissions The anti-pattern: the whole namespace inherits everything
Dedicated cluster-admin Perfect identity, catastrophic permissions
No token mounted Any It cannot talk to the API. The right thing for 5 of 6 components

Look at the last row, because it is the most important conclusion in practical terms: the best way to manage the permissions of a pod that does not need the API is for it to have no token. No misconfigured RBAC can hurt it if there is no credential to use.

What is left for 08-01: the Role and ClusterRole objects (which verbs on which resources), the RoleBinding and ClusterRoleBinding objects (to whom), role aggregation, the cluster's predefined roles (view, edit, admin, cluster-admin), and the privilege escalation to be avoided. All of that rests on the identity you created today.

  1. Identity federation with the clouds

One last note so you know it exists, because it is what you will use if Rutas Norte moves to a managed cluster.

The problem: occupancy-reports has to write its reports to a cloud storage bucket (s3://reports-rutasnorte/). The old way would be to create a permanent access key at the provider and store it in a Secret. That reintroduces every problem we saw in the Secrets lesson: a static credential that has to be rotated by hand and never expires.

The modern solution is called workload identity federation: the cloud provider trusts the Kubernetes cluster's token issuer and accepts the ServiceAccount token as proof of identity, handing back temporary credentials in exchange.

flowchart LR
    P["Pod with SA<br/>occupancy-reports"] --> T["Projected token<br/>audience: sts.amazonaws.com"]
    T --> S["Cloud STS<br/>verifies the signature of the<br/>cluster's OIDC issuer"]
    S --> C["TEMPORARY credentials<br/>expire in 1 hour"]
    C --> B["Reports bucket"]

What it is called at each provider:

Provider Name How it is associated
AWS (EKS) IRSA (IAM Roles for Service Accounts) or Pod Identity The eks.amazonaws.com/role-arn annotation on the ServiceAccount
Google (GKE) Workload Identity The iam.gke.io/gcp-service-account annotation
Azure (AKS) Workload Identity The azure.workload.identity/client-id annotation + a label on the pod

An example, just so you recognise the pattern:

apiVersion: v1
kind: ServiceAccount
metadata:
  name: occupancy-reports
  namespace: rutas-norte-pro
  annotations:
    eks.amazonaws.com/role-arn: arn:aws:iam::123456789012:role/rutasnorte-reports

With that annotation, the AWS SDK inside the pod obtains temporary credentials automatically, with no static key in any Secret. The Kubernetes ServiceAccount becomes an identity recognised outside the cluster too.

The advantages are the same ones you already know from the projected token: nothing permanent, automatic rotation, limited scope, and immediate revocation when the association is deleted. The detail of configuring it at each provider is the subject of Managed Kubernetes.

Common Mistakes and Tips

Mistake Symptom Fix
Using the default ServiceAccount No traceability, shared permissions One ServiceAccount per component
Leaving the token mounted on every pod Unnecessary attack surface automountServiceAccountToken: false
Changing the ServiceAccount without recreating pods Old pods keep the previous one kubectl rollout restart
Using serviceAccount instead of serviceAccountName It works but it is deprecated serviceAccountName
Reading the token only once at start-up 401 exactly one hour later Re-read it on every request, or use a client library
Using curl -k against the apiserver Vulnerable to impersonation --cacert /var/run/secrets/.../ca.crt
Confusing 401 and 403 You look for the problem in the wrong place 401 = token; 403 = permissions
Creating a permanent token Secret An eternal, irrevocable credential kubectl create token --duration
Expecting that creating an SA grants permissions Everything returns 403 Kubernetes denies by default: RBAC is needed
A ServiceAccount from another namespace The pod does not start They must be in the same namespace
imagePullSecrets repeated on every pod It gets forgotten on the new component Declare it on the ServiceAccount
Storing static cloud keys in Secrets Credentials that never rotate Identity federation

Tips:

  1. Create the ServiceAccount next to the Deployment, in the same file. So there is never a component without its own identity.
  2. automountServiceAccountToken: false by default in the base template. Make mounting the token a conscious decision, not inherited behaviour.
  3. Audit periodically which pods carry a token. The jq command from section 7 should return only system components and those that genuinely need it.
  4. Use kubectl auth can-i --list --as=... in reviews. It is the quick way to see an identity's effective permissions before approving a change.
  5. Never grant permissions to the default. It is the fastest route to a privilege escalation across the whole namespace.

Exercises

Exercise 1: Give the platform an identity

  1. Create the six Rutas Norte ServiceAccounts in rutas-norte-pro with the module 2 labelling scheme, setting automountServiceAccountToken: false on all of them except occupancy-reports.
  2. Modify the five existing Deployments so that they use their ServiceAccount and recreate the pods.
  3. Show with a single command that each pod uses its own.
  4. Show that web-store no longer has the token directory and that occupancy-reports does.
  5. Write a command that lists every pod in the cluster that does have the token mounted and explain which of them are legitimate.

Exercise 2: Talk to the API and understand the 403

  1. Create a pod called api-client with the occupancy-reports ServiceAccount and the token mounted.
  2. From inside, query /version and /apis/authentication.k8s.io/v1/selfsubjectreviews and show the full identity.
  3. Try to list the namespace's pods, capture the HTTP code and the full message, and identify the verb, the resource, the API group and the scope in it.
  4. Decode the token payload and find sub, exp, the pod it is bound to and its lifetime in minutes.
  5. Try the same request without the Authorization header and with a tampered token (change one character). Explain the codes you get in each case and why they differ.

Exercise 3: Identity and attack surface audit

You are asked to run an identity audit of the platform ahead of a security review.

  1. Draw up a table with the six components showing: ServiceAccount, whether it mounts a token, whether it has imagePullSecrets and what effective permissions it has.
  2. Identify which pods in the cluster (including kube-system) mount a token and classify them as "legitimate" or "to review".
  3. Simulate an incident: an attacker manages to run code inside the web-store container. List what they can do with the current configuration and what they could have done before this lesson.
  4. Explain why imagePullSecrets on the default ServiceAccount is acceptable but granting RBAC permissions to the default is not.
  5. Propose three further hardening measures, saying in which lesson of the course each one is covered.

Solutions

Solution 1

The k8s/base/serviceaccounts.yaml file is the one from section 4. Applying it and modifying the Deployments:

kubectl apply -f k8s/base/serviceaccounts.yaml

for C in web-store bookings-api bookings-postgres redis-cache notifications-worker; do
  kubectl patch deployment $C -n rutas-norte-pro -p \
    "{\"spec\":{\"template\":{\"spec\":{\"serviceAccountName\":\"$C\",
       \"automountServiceAccountToken\":false}}}}"
done

kubectl rollout status deploy/bookings-api -n rutas-norte-pro
serviceaccount/web-store created
serviceaccount/bookings-api created
serviceaccount/bookings-postgres created
serviceaccount/redis-cache created
serviceaccount/notifications-worker created
serviceaccount/occupancy-reports created
deployment.apps/web-store patched
deployment.apps/bookings-api patched
deployment.apps/bookings-postgres patched
deployment.apps/redis-cache patched
deployment.apps/notifications-worker patched
deployment "bookings-api" successfully rolled out
kubectl get pods -n rutas-norte-pro -o custom-columns=\
'POD:.metadata.name,SA:.spec.serviceAccountName,TOKEN:.spec.automountServiceAccountToken'
POD                                     SA                      TOKEN
bookings-api-8b5c7d9e2-4mkqp            bookings-api            false
bookings-api-8b5c7d9e2-9wnzr            bookings-api            false
bookings-postgres-6e9g7c0d5-l3nqx       bookings-postgres       false
redis-cache-7d0e9g8c6-wo5ry             redis-cache             false
web-store-6c8d0g5e9-i8qol               web-store               false
notifications-worker-7g8e0d9c5-mn4qu    notifications-worker    false
kubectl exec -n rutas-norte-pro deploy/web-store -- ls /var/run/secrets/kubernetes.io/ 2>&1
kubectl run reports-test --image=curlimages/curl:8.10.1 -n rutas-norte-pro \
  --overrides='{"spec":{"serviceAccountName":"occupancy-reports"}}' \
  --restart=Never -- sleep 60
sleep 5
kubectl exec -n rutas-norte-pro reports-test -- ls /var/run/secrets/kubernetes.io/serviceaccount/
ls: /var/run/secrets/kubernetes.io/: No such file or directory
command terminated with exit code 1

pod/reports-test created
ca.crt
namespace
token

The audit of mounted tokens:

kubectl get pods -A -o json | jq -r '
  .items[] |
  select(.spec.automountServiceAccountToken != false) |
  "\(.metadata.namespace)\t\(.metadata.name)\t\(.spec.serviceAccountName)"' | column -t
kube-system       coredns-7db6d8ff4d-2vqxn          coredns
kube-system       kube-proxy-8xvmk                  kube-proxy
kube-system       storage-provisioner               storage-provisioner
kube-system       metrics-server-7d9f8c6b4-x2mkl    metrics-server
ingress-nginx     ingress-nginx-controller-9wnzr    ingress-nginx
rutas-norte-pro   reports-test                      occupancy-reports

All legitimate: CoreDNS watches Services and Endpoints, kube-proxy does too, metrics-server publishes an aggregated API, the Ingress controller reads Ingress objects, and occupancy-reports is ours. From Rutas Norte, only one of six.

Solution 2

kubectl apply -f api-client.yaml
kubectl exec -it api-client -n rutas-norte-pro -- sh

Inside the pod:

SA=/var/run/secrets/kubernetes.io/serviceaccount
TOKEN=$(cat $SA/token); NS=$(cat $SA/namespace); API=https://kubernetes.default.svc

curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" $API/version | head -4

curl -s --cacert $SA/ca.crt -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -X POST $API/apis/authentication.k8s.io/v1/selfsubjectreviews \
  -d '{"apiVersion":"authentication.k8s.io/v1","kind":"SelfSubjectReview"}' \
  | grep username
{
  "major": "1",
  "minor": "30",
  "gitVersion": "v1.30.4",

    "username": "system:serviceaccount:rutas-norte-pro:occupancy-reports",
curl -s -w "\nHTTP: %{http_code}\n" --cacert $SA/ca.crt \
  -H "Authorization: Bearer $TOKEN" $API/api/v1/namespaces/$NS/pods | grep -E "message|HTTP"
  "message": "pods is forbidden: User \"system:serviceaccount:rutas-norte-pro:occupancy-reports\"
   cannot list resource \"pods\" in API group \"\" in the namespace \"rutas-norte-pro\"",
HTTP: 403

Breaking the message down:

Element Value
Verb list
Resource pods
API group "" (core)
Scope namespace rutas-norte-pro
Identity system:serviceaccount:rutas-norte-pro:occupancy-reports

The token payload:

cut -d. -f2 $SA/token | base64 -d 2>/dev/null | tr ',' '\n' | grep -E '"sub"|"exp"|"iat"|"pod"'
"sub":"system:serviceaccount:rutas-norte-pro:occupancy-reports"
"exp":1785703921
"iat":1785700321
"pod":{"name":"api-client"
(1785703921 - 1785700321) / 60 = 60 minutes

With no header and with a tampered token:

curl -s -o /dev/null -w "no token:   %{http_code}\n" --cacert $SA/ca.crt \
  $API/api/v1/namespaces/$NS/pods
curl -s -o /dev/null -w "bad token:  %{http_code}\n" --cacert $SA/ca.crt \
  -H "Authorization: Bearer ${TOKEN}X" $API/api/v1/namespaces/$NS/pods
no token:   403
bad token:  401

The difference is subtle and instructive:

  • No header → 403. It is not that the apiserver does not know who you are: it classifies you as the anonymous user system:anonymous, which is a valid identity with no permissions. It is a case of successful authentication as anonymous and denied authorization. (If the cluster had anonymous access disabled, you would indeed get a 401.)
  • Tampered token → 401. The JWT signature does not validate, so the apiserver cannot establish any identity. It is a pure authentication failure.

That distinction is what will tell you, in a real incident, whether your problem is the credential or the permissions.

Solution 3

  1. The identity table:
Component ServiceAccount Token? imagePullSecrets Effective permissions
web-store web-store No registry-rutasnorte None (no credential)
bookings-api bookings-api No registry-rutasnorte None (no credential)
bookings-postgres bookings-postgres No — (public image) None (no credential)
redis-cache redis-cache No — (public image) None (no credential)
notifications-worker notifications-worker No registry-rutasnorte None (no credential)
occupancy-reports occupancy-reports Yes registry-rutasnorte Only selfsubject*, /healthz, /version
  1. Classifying the pods with a token, using the command from solution 1: everything in kube-system and the Ingress controller are legitimate (they need to watch API objects in order to work). occupancy-reports is legitimate and expected. If any other rutas-norte-* pod showed up, it would be an immediate "to review".

  2. The simulated incident. An attacker with code execution in web-store:

Before this lesson Now
Read the SA token Yes: /var/run/secrets/.../token The file does not exist
Identify itself to the API Yes, as ...:default No: it would be system:anonymous
Enumerate pods, services, deployments Depends on RBAC on default No
Read the namespace's Secrets If default had get secrets, yes: including the bookings-postgres password with the customers' personal data No
Create pods to escalate privileges Depends on RBAC No
Reach bookings-postgres over the network Yes (the namespace does not isolate the network) Yes, still

The fourth row is what gives the measure of the risk: in a cluster where somebody had granted permissions to the default "just to test", a vulnerability in a static file server would have allowed reading the database credentials holding the personal data of every Rutas Norte customer. Removing the token eliminates that route entirely.

The last row points at what this lesson does not solve: the network is still flat. Any pod in the namespace can open a TCP connection to bookings-postgres:5432. That is closed by the network policies.

  1. imagePullSecrets on the default is acceptable because it grants pods no capability at all: it only lets the kubelet pull images from the private registry, which is an operation that happens before start-up and not an action the container process can exercise. The contents of that Secret are not even visible from inside the pod.

Granting RBAC permissions to the default is not acceptable because the default is, by definition, used by every pod that does not specify otherwise: any new pod, any debugging pod, any manifest pasted from the internet. A permission granted there is given to an open and unpredictable set of workloads, present and future. It is the opposite of an explicit decision.

  1. Three further hardening measures:
Measure What it brings Lesson
RBAC with minimal permissions So occupancy-reports can read only what it needs, and nobody else anything 08-01
NetworkPolicies So only bookings-api and notifications-worker can open connections to bookings-postgres:5432 04-06
SecurityContext and Pod Security Standards A container with no root, a read-only file system, no capabilities and no privilege escalation: it makes code execution far less useful 08-02 and 08-03

All three, together with today's work, form the layers of a defence in depth: minimal identity, minimal permissions, minimal network and minimal container.

Conclusion

You have closed module 3 by giving the platform an identity. You know that Kubernetes distinguishes between users, which are not cluster objects and are managed by an external system, and ServiceAccounts, which are, are namespaced and identify themselves as system:serviceaccount:<ns>:<name>. You know the five reasons not to use the default — it is shared, it destroys traceability, it prevents selective revocation, it mounts a token almost nobody needs and it tempts people to grant it permissions — and you have created a dedicated ServiceAccount for each of the six Rutas Norte components, assigning it with serviceAccountName.

You understand how the token has changed: from those Secrets with eternal JWTs, tied to no pod and stored in etcd, to the projected tokens of the TokenRequest API, which expire in an hour, are rotated by the kubelet at 80% of their lifetime, are bound to a specific pod UID, live only in tmpfs and are invalidated when the pod is deleted. You know what lives in /var/run/secrets/kubernetes.io/serviceaccount/token, ca.crt and namespace — and what each file is for, including the ca.crt that saves you from using --insecure. And you are clear about the precedence of automountServiceAccountToken, with the recommended pattern: false on the ServiceAccount as a safety net and an explicit true only where it is genuinely needed. In Rutas Norte, five of six components no longer carry any credential inside, which eliminates at the root the first move of any privilege escalation script.

You have talked to the API from inside a pod with curl, assembling the request by hand with the CA, the token and the kubernetes.default.svc Service; you have asked who you are with SelfSubjectReview; and you have received a 403 Forbidden that you can break down into verb, resource, API group and scope, distinguishing it from the 401 that signals a credential problem rather than a permissions one. That 403 was not a failure: it was the demonstration that Kubernetes denies by default and that creating an identity grants absolutely nothing. You have moved imagePullSecrets onto the ServiceAccount so it is neither repeated nor forgotten, and you know that identity federation with the clouds exists so that even external credentials need not be static.

With this you close module 3 and the Rutas Norte platform is in far better health than it was six lessons ago. The configuration lives outside the image, in per-environment ConfigMaps; the bookings-postgres password is no longer in Git but in a Secret mounted in memory with only the keys each component needs; the environment variables are catalogued component by component and environment by environment, with the Downward API giving traceability to every request; the three environments have a quota and a LimitRange, so a mistake in development cannot touch production; every pod has a QoS class assigned on business grounds, with bookings-postgres as Guaranteed and last survivor; and each component has its own identity before the API, almost always with no credential mounted.

But the platform still has a very visible hole: the network is completely flat. Any pod in rutas-norte-pro can open a connection to bookings-postgres:5432, and no client on the internet can yet reach www.rutasnorte.example or api.rutasnorte.example, because our Services are all ClusterIP and exist only inside the cluster. Module 4, Networking in Kubernetes, tackles that from end to end: how the cluster network really works and the "every pod with its own IP" model, the Service types beyond ClusterIP, the internal DNS we have been using without fully explaining it, the Ingress controllers that will finally publish the store and the API on their domains, the TLS certificates managed automatically with cert-manager, and the network policies that will stop anyone who is not bookings-api or notifications-worker from even attempting to connect to the database holding the customers' personal data.

Kubernetes Course

Module 1: Introduction to Kubernetes

Module 2: Core Kubernetes Components

Module 3: Configuration and Secret Management

Module 4: Networking in Kubernetes

Module 5: Storage in Kubernetes

Module 6: Advanced Kubernetes Concepts

Module 7: Monitoring and Logging

Module 8: Kubernetes Security

Module 9: Scaling and Performance

Module 10: Kubernetes Ecosystem and Tooling

Module 11: Case Studies and Real-World Applications

Module 12: Preparing for Kubernetes Certification

© Copyright 2026. All rights reserved