06-security-packaging-operations

RBAC, ServiceAccounts and Pod Security Admission

Who can do what, which identity workloads use, and why PodSecurityPolicy tutorials will waste your time — it was removed in v1.25.

September 13, 2026
kubernetesrbacserviceaccountpod-security-admissionsecuritycontextleast-privilege

Two Different Questions

Kubernetes security splits into two questions that get confused constantly:

  1. Who can call the API, and what can they do? — RBAC.
  2. What is a running pod allowed to do on its node? — Pod Security Admission and securityContext.

RBAC won't stop a container running as root. Pod Security Admission won't stop someone deleting your Deployments. You need both.

RBAC: Four Objects

A Role lists permitted verbs on resources within one namespace:

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: pod-reader
  namespace: production
rules:
  - apiGroups: [""]
    resources: ["pods", "pods/log"]
    verbs: ["get", "list", "watch"]

A RoleBinding grants it to a subject:

yaml
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: read-pods
  namespace: production
subjects:
  - kind: ServiceAccount
    name: monitoring
    namespace: production
roleRef:
  kind: Role
  name: pod-reader
  apiGroup: rbac.authorization.k8s.io

RBAC is purely additive — there are no deny rules. A subject can do the union of everything granted to it, and nothing else. Access is denied by default.

The namespaced/cluster-scoped distinction

This is where permissions accidentally become enormous:

ObjectScope
Role + RoleBindingOne namespace
ClusterRole + ClusterRoleBindingThe entire cluster
ClusterRole + RoleBindingThe ClusterRole's rules, limited to that binding's namespace

That third combination is genuinely useful — define a reusable ClusterRole once, then bind it per namespace.

🚨

A ClusterRoleBinding applies across every namespace. Binding a ClusterRole with get secrets to a ServiceAccount via ClusterRoleBinding grants it every Secret in the cluster — including other teams' database credentials and cloud keys. If you meant "this namespace", use a RoleBinding. This substitution is one of the most common real-world privilege-escalation paths.

ServiceAccounts

Every pod runs as a ServiceAccount — the default one in its namespace if you don't specify. That's the identity it presents to the API server.

yaml
apiVersion: v1
kind: ServiceAccount
metadata:
  name: api
  namespace: production
automountServiceAccountToken: false     # don't mount unless needed
---
spec:
  template:
    spec:
      serviceAccountName: api
⚠️

By default a token is mounted into every pod at /var/run/secrets/kubernetes.io/serviceaccount/. Most applications never call the Kubernetes API and don't need it — and a mounted token is something an attacker who compromises your container can use. Set automountServiceAccountToken: false unless the workload genuinely talks to the API.

Modern tokens are projected: short-lived, audience-bound, and automatically rotated, rather than permanent Secrets. If you find guidance about long-lived ServiceAccount token Secrets, it predates this change.

Check what a ServiceAccount can actually do:

bash
kubectl auth can-i --list --as=system:serviceaccount:production:api
kubectl auth can-i delete deployments --as=system:serviceaccount:production:api -n production

kubectl auth can-i is the fastest way to verify RBAC without deploying anything — and worth running on any ServiceAccount you inherit.

Check yourself

A ClusterRole granting get/list on secrets is bound to a ServiceAccount using a ClusterRoleBinding, intending to let a monitoring agent read secrets in its own namespace. What was actually granted?

Pod Security: Don't Follow PSP Tutorials

PodSecurityPolicy was deprecated in v1.21 and removed entirely in v1.25. It no longer exists in the API. Tutorials that tell you to write a PSP — and there are many — are describing a mechanism your cluster does not have.

Its replacement is Pod Security Admission, built into the API server. Instead of writing policy objects, you label a namespace with one of three standards:

yaml
apiVersion: v1
kind: Namespace
metadata:
  name: production
  labels:
    pod-security.kubernetes.io/enforce: restricted
    pod-security.kubernetes.io/enforce-version: latest
    pod-security.kubernetes.io/warn: restricted
    pod-security.kubernetes.io/audit: restricted

The three Pod Security Standards:

StandardMeaning
privilegedUnrestricted — anything goes
baselineBlocks known privilege escalations; minimally restrictive
restrictedHeavily restricted, following current hardening best practice

And three modes, which can be combined:

  • enforce — reject non-compliant pods.
  • warn — allow, but return a warning to the user.
  • audit — allow, but record it in the audit log.

The adoption path this enables is the reason to like PSA. Label namespaces with warn and audit at restricted first, leaving enforce unset. Nothing breaks, and you collect a complete list of what would fail. Fix those workloads, then switch on enforce. Going straight to enforcement on a live namespace breaks deployments immediately.

securityContext: Satisfying restricted

The restricted standard requires specific securityContext settings:

yaml
spec:
  securityContext:
    runAsNonRoot: true
    runAsUser: 10001
    fsGroup: 10001
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: api
      securityContext:
        allowPrivilegeEscalation: false
        readOnlyRootFilesystem: true
        capabilities:
          drop: ["ALL"]
      volumeMounts:
        - name: tmp
          mountPath: /tmp
  volumes:
    - name: tmp
      emptyDir: {}

Each field does real work:

  • runAsNonRoot — refuse to start if the image would run as UID 0.
  • allowPrivilegeEscalation: false — block gaining privileges via setuid binaries.
  • readOnlyRootFilesystem: true — the container can't write to its own filesystem, so add a writable emptyDir wherever it genuinely needs one.
  • capabilities.drop: ["ALL"] — remove all Linux capabilities. Add back only what's needed (NET_BIND_SERVICE for ports below 1024).
  • seccompProfile: RuntimeDefault — restrict available syscalls.
💡

These are the Kubernetes expression of the Docker hardening guide's least-privilege settings. Building images that run as non-root with a numeric USER makes satisfying restricted almost automatic — the two roadmaps meet here.

Beyond PSA: Policy Engines

Pod Security Admission covers the built-in standards only. For organisation-specific rules — required labels, permitted registries, mandatory resource limits — use a policy engine such as Kyverno or OPA Gatekeeper. Both run as admission webhooks and can validate, mutate, or generate resources.

A common pairing: PSA for the baseline pod-hardening standards, a policy engine for everything specific to you.

An Audit Checklist

bash
# Who has cluster-admin?
kubectl get clusterrolebindings -o json | \
  jq -r '.items[] | select(.roleRef.name=="cluster-admin") | .metadata.name'
 
# What can a given ServiceAccount do?
kubectl auth can-i --list --as=system:serviceaccount:production:api
 
# Which namespaces have no Pod Security labels?
kubectl get ns -o json | \
  jq -r '.items[] | select(.metadata.labels["pod-security.kubernetes.io/enforce"]==null) | .metadata.name'
 
# Which pods mount a ServiceAccount token unnecessarily?
kubectl get pods -A -o json | \
  jq -r '.items[] | select(.spec.automountServiceAccountToken != false) | "\(.metadata.namespace)/\(.metadata.name)"'

Check yourself

A team finds a tutorial explaining how to write a PodSecurityPolicy to enforce non-root containers. They're on Kubernetes 1.37. What should they do?

What's Next

The cluster is secured. The next guide covers managing manifests at scale — Helm and Kustomize, and the render-before-apply habit that makes any generated configuration safe to trust.