CKS — ServiceAccount Security

8 minute read

ServiceAccount Security

ServiceAccounts are the identity mechanism for workloads running inside the cluster. Every Pod runs as a ServiceAccount, and by default, that token is auto-mounted and may carry more access than the workload needs. Hardening ServiceAccounts is a core CKS skill.


Default ServiceAccount Risks

Every namespace has a default ServiceAccount created automatically. Unless you specify otherwise, every Pod in that namespace uses it.

What Happens by Default

Pod created without serviceAccountName
       │
       ▼
Uses "default" ServiceAccount
       │
       ▼
Token auto-mounted at /var/run/secrets/kubernetes.io/serviceaccount/token
       │
       ▼
Pod can authenticate to the API server as system:serviceaccount:<ns>:default

Why This Is a Problem

  1. Shared identity — All Pods in the namespace share the same SA. If one Pod is compromised, the attacker gets the same token as every other Pod.
  2. Unnecessary API access — Most application Pods never need to talk to the Kubernetes API, yet they carry a valid token.
  3. RBAC amplification — If someone binds roles to the default ServiceAccount, every Pod in that namespace inherits those permissions.

Verifying Default SA Token Mount

# Check if a Pod has the token mounted
kubectl exec -it <pod-name> -- ls /var/run/secrets/kubernetes.io/serviceaccount/
# Output: ca.crt  namespace  token

# Decode and inspect the token
kubectl exec -it <pod-name> -- cat /var/run/secrets/kubernetes.io/serviceaccount/token | \
  cut -d. -f2 | base64 -d 2>/dev/null | jq .
# From inside a compromised Pod, an attacker can:
TOKEN=$(cat /var/run/secrets/kubernetes.io/serviceaccount/token)
curl -sk -H "Authorization: Bearer $TOKEN" \
  https://kubernetes.default.svc/api/v1/namespaces/default/secrets

Disabling Token Auto-Mount

The most effective mitigation is to disable automatic token mounting. This can be set at two levels:

Pod-Level: automountServiceAccountToken

apiVersion: v1
kind: Pod
metadata:
  name: app
  namespace: app-ns
spec:
  automountServiceAccountToken: false    # ← Disable for this Pod
  containers:
  - name: app
    image: nginx:1.25

ServiceAccount-Level: automountServiceAccountToken

apiVersion: v1
kind: ServiceAccount
metadata:
  name: default
  namespace: app-ns
automountServiceAccountToken: false      # ← Disable for all Pods using this SA

Precedence Rules

SA Setting Pod Setting Result
Not set (default: true) Not set (default: true) Mounted
false Not set Not mounted
true false Not mounted
false true Mounted

Pod-level setting always wins when explicitly set.

CKS Exam Tip: The exam may ask you to disable token auto-mount for the default ServiceAccount in a namespace. Patch it directly:

kubectl patch serviceaccount default -n <namespace> \
  -p '{"automountServiceAccountToken": false}'

Dedicated ServiceAccounts Per Workload

Never let multiple workloads share a ServiceAccount. Each workload should have its own SA with only the permissions it needs.

Creating a Dedicated SA

apiVersion: v1
kind: ServiceAccount
metadata:
  name: order-processor
  namespace: app-ns
automountServiceAccountToken: false   # Only mount if needed
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: order-processor
  namespace: app-ns
spec:
  replicas: 2
  selector:
    matchLabels:
      app: order-processor
  template:
    metadata:
      labels:
        app: order-processor
    spec:
      serviceAccountName: order-processor    # ← Dedicated SA
      automountServiceAccountToken: false     # ← No token unless needed
      containers:
      - name: processor
        image: myapp/order-processor:v2

When the Workload DOES Need API Access

If the workload genuinely needs to talk to the Kubernetes API (e.g., a controller, operator, or service discovery tool):

apiVersion: v1
kind: ServiceAccount
metadata:
  name: config-watcher
  namespace: app-ns
# automountServiceAccountToken defaults to true — intentional
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: config-watcher-role
  namespace: app-ns
rules:
- apiGroups: [""]
  resources: ["configmaps"]
  verbs: ["get", "watch", "list"]     # ← Minimum required permissions
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: config-watcher-binding
  namespace: app-ns
subjects:
- kind: ServiceAccount
  name: config-watcher
  namespace: app-ns
roleRef:
  kind: Role
  name: config-watcher-role
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: config-watcher
  namespace: app-ns
spec:
  replicas: 1
  selector:
    matchLabels:
      app: config-watcher
  template:
    metadata:
      labels:
        app: config-watcher
    spec:
      serviceAccountName: config-watcher
      # Token IS auto-mounted — this workload needs it
      containers:
      - name: watcher
        image: myapp/config-watcher:v1

Token Projection and Bound Tokens

Starting in Kubernetes 1.20+, the default token mechanism changed from long-lived Secret-based tokens to bound, time-limited, audience-scoped projected tokens.

Legacy Tokens vs. Bound Tokens

Feature Legacy (Secret-based) Bound (Projected)
Lifetime Never expires Time-limited (default 1 hour, auto-rotated)
Audience None (valid for any audience) Scoped to API server
Storage Stored as a Secret in etcd Generated on demand, not stored
Revocation Persists until Secret deleted Invalidated when Pod deleted
Kubernetes version 1.20 and earlier (default) 1.20+ (default)

How Token Projection Works

When automountServiceAccountToken: true (default), Kubernetes uses a projected volume to provide the token:

# This is what Kubernetes injects automatically (you don't write this)
volumes:
- name: kube-api-access-xxxxx
  projected:
    sources:
    - serviceAccountToken:
        expirationSeconds: 3607
        path: token
    - configMap:
        name: kube-root-ca.crt
        items:
        - key: ca.crt
          path: ca.crt
    - downwardAPI:
        items:
        - path: namespace
          fieldRef:
            fieldPath: metadata.namespace

Explicitly Using Token Projection

For workloads that need a token with a custom audience or lifetime:

apiVersion: v1
kind: Pod
metadata:
  name: custom-token-pod
  namespace: app-ns
spec:
  serviceAccountName: my-app-sa
  automountServiceAccountToken: false   # Disable default mount
  containers:
  - name: app
    image: myapp:v1
    volumeMounts:
    - name: custom-token
      mountPath: /var/run/secrets/tokens
      readOnly: true
  volumes:
  - name: custom-token
    projected:
      sources:
      - serviceAccountToken:
          path: api-token
          expirationSeconds: 600        # 10 minutes
          audience: "https://my-api.example.com"

API Server Configuration for Bound Tokens

The API server must have these flags for bound token projection (enabled by default in 1.20+):

# /etc/kubernetes/manifests/kube-apiserver.yaml
- --service-account-issuer=https://kubernetes.default.svc
- --service-account-signing-key-file=/etc/kubernetes/pki/sa.key
- --service-account-key-file=/etc/kubernetes/pki/sa.pub

Disabling Token Auto-Mount Cluster-Wide

For maximum security, disable auto-mount on the default ServiceAccount in every namespace.

Manual Approach

# Patch default SA in all existing namespaces
for ns in $(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}'); do
  kubectl patch serviceaccount default -n "$ns" \
    -p '{"automountServiceAccountToken": false}'
  echo "Patched default SA in namespace: $ns"
done

Enforcement via Admission Webhook

For new namespaces, use a mutating admission webhook or a policy engine (Kyverno, OPA Gatekeeper) to automatically patch the default ServiceAccount:

# Kyverno ClusterPolicy example
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: disable-default-sa-automount
spec:
  rules:
  - name: patch-default-sa
    match:
      any:
      - resources:
          kinds:
          - ServiceAccount
          names:
          - default
    mutate:
      patchStrategicMerge:
        automountServiceAccountToken: false

Verifying the Patch

# Check default SA in each namespace
kubectl get serviceaccounts default -A -o json | jq -r '
  .items[] |
  "\(.metadata.namespace): automount=\(.automountServiceAccountToken // "true (default)")"
'

ServiceAccount RBAC Hardening Checklist

Check Command Expected
Default SA has no extra bindings kubectl get rolebindings,clusterrolebindings -A -o json \| jq '.items[] \| select(.subjects[]?.name == "default")' Empty or system-only
Default SA auto-mount disabled kubectl get sa default -n <ns> -o jsonpath='{.automountServiceAccountToken}' false
Each workload has dedicated SA kubectl get pods -n <ns> -o jsonpath='{range .items[*]}{.metadata.name}: {.spec.serviceAccountName}{"\n"}{end}' No two workloads share SA
Application Pods don’t mount tokens kubectl get pods -n <ns> -o json \| jq '.items[] \| select(.spec.automountServiceAccountToken != false) \| .metadata.name' Only Pods that need API access

Cleaning Up Legacy Secret-Based Tokens

In Kubernetes 1.24+, the LegacyServiceAccountTokenNoAutoGeneration feature gate means new SAs no longer get auto-generated Secret tokens. However, clusters upgraded from older versions may still have them.

# Find legacy SA token Secrets
kubectl get secrets -A -o json | jq -r '
  .items[] |
  select(.type == "kubernetes.io/service-account-token") |
  "\(.metadata.namespace)/\(.metadata.name) → SA: \(.metadata.annotations["kubernetes.io/service-account.name"])"
'

# Delete unused legacy tokens (verify they're not in use first)
kubectl delete secret <legacy-token-secret> -n <namespace>

Practice Questions

Q1. A Pod in the finance namespace is running with the default ServiceAccount. The Pod does not need API access. How do you prevent the ServiceAccount token from being mounted?

Show Answer

Two approaches — use both for defense in depth:

Option A: Patch the Pod spec

spec:
  automountServiceAccountToken: false

Option B: Patch the default ServiceAccount

kubectl patch serviceaccount default -n finance \
  -p '{"automountServiceAccountToken": false}'

Option B is preferred because it applies to all Pods using the default SA in that namespace without requiring individual Pod changes.


Q2. What is the difference between a legacy ServiceAccount token and a bound (projected) token?

Show Answer
Feature Legacy Token Bound Token
Storage Stored as a Secret in etcd Not stored — generated by TokenRequest API
Lifetime Never expires Time-limited (default ~1hr, auto-rotated by kubelet)
Audience Not scoped Scoped to API server (or custom audience)
Tied to Pod No — persists after Pod deletion Yes — invalidated when Pod is deleted
K8s version Default before 1.20 Default 1.20+

Bound tokens are strictly more secure because they limit the window of exploitation if a token is stolen.


Q3. How do you verify from inside a running Pod whether a ServiceAccount token is mounted?

Show Answer
# Check for token file
ls -la /var/run/secrets/kubernetes.io/serviceaccount/

# If the directory exists and contains "token", a token is mounted
cat /var/run/secrets/kubernetes.io/serviceaccount/token

# Decode the JWT to see the SA identity (middle segment)
cat /var/run/secrets/kubernetes.io/serviceaccount/token | \
  cut -d. -f2 | base64 -d 2>/dev/null | python3 -m json.tool

If the directory doesn’t exist or is empty, automountServiceAccountToken is set to false.


Q4. You need to create a ServiceAccount for a monitoring agent that reads Pod metrics. Write the complete YAML for the SA, Role, RoleBinding, and a Pod that uses it.

Show Answer
apiVersion: v1
kind: ServiceAccount
metadata:
  name: metrics-reader
  namespace: monitoring
---
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: metrics-reader-role
  namespace: monitoring
rules:
- apiGroups: [""]
  resources: ["pods"]
  verbs: ["get", "list", "watch"]
- apiGroups: ["metrics.k8s.io"]
  resources: ["pods"]
  verbs: ["get", "list"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: metrics-reader-binding
  namespace: monitoring
subjects:
- kind: ServiceAccount
  name: metrics-reader
  namespace: monitoring
roleRef:
  kind: Role
  name: metrics-reader-role
  apiGroup: rbac.authorization.k8s.io
---
apiVersion: v1
kind: Pod
metadata:
  name: metrics-agent
  namespace: monitoring
spec:
  serviceAccountName: metrics-reader
  # automountServiceAccountToken: true (default) — this Pod needs it
  containers:
  - name: agent
    image: monitoring/metrics-agent:v1

Q5. How do you disable token auto-mount for the default ServiceAccount in all namespaces at once?

Show Answer
# Patch all existing namespaces
for ns in $(kubectl get namespaces -o jsonpath='{.items[*].metadata.name}'); do
  kubectl patch serviceaccount default -n "$ns" \
    -p '{"automountServiceAccountToken": false}'
done

# Verify
kubectl get serviceaccounts default -A -o json | jq -r '
  .items[] |
  "\(.metadata.namespace): \(.automountServiceAccountToken)"
'

For new namespaces, use a policy engine (Kyverno or OPA Gatekeeper) with a mutating rule that automatically patches the default ServiceAccount when it’s created.


Previous Up Next
RBAC Best Practices CKS Home Upgrade Frequency

Updated: