CKS — ServiceAccount Security
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
- 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.
- Unnecessary API access — Most application Pods never need to talk to the Kubernetes API, yet they carry a valid token.
- RBAC amplification — If someone binds roles to the
defaultServiceAccount, 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
defaultServiceAccount 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 |