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.