click the screen · press Enter
← back to blog
Kubernetes Journey · Topic 29

Pod Security Policies Are Gone, and the Replacement Is Better

Pod Security Policies compared with Pod Security Admission, the RBAC-bound admission control Kubernetes removed in version 1.25 and its label-based replacement - Kubernetes Journey

I was tidying up my CKS notes and kept tripping over a resource called PodSecurityPolicy. Half the study material still leans on it. So I went to try it on my own cluster, and Kubernetes told me flatly that the resource doesn't exist. I'd been reading up on something that's been gone since version 1.25.

That's not wasted time though. A fair few clusters out in the wild are still running versions old enough to have PSPs alive and enforcing, and knowing what they did, and specifically why they got killed off, makes Pod Security Admission click a lot faster than reading its docs cold. So this topic is the bit I skipped last time: the tool PodSecurity replaced, and the two profiles I never actually showed you.

Pod Security Policies versus Pod Security Admission: PSPs bound cluster-wide policy objects through RBAC Roles and could silently lock out every pod, while PSA reads a namespace label directly and only validates, never mutates
Same job, enforce a security baseline on pods. Completely different plumbing, and the plumbing is exactly what got PSPs killed.

What a Pod Security Policy actually did

What it was: a cluster-scoped object that described which security settings a Pod was allowed to request, checked by an admission controller before the Pod was ever saved to etcd.

Take a Pod that asks for far more than it needs:

apiVersion: v1
kind: Pod
metadata:
  name: sample-pod
spec:
  containers:
    - name: ubuntu
      image: ubuntu
      command: ["sleep", "3600"]
      securityContext:
        privileged: true
        runAsUser: 0
        capabilities:
          add: ["CAP_SYS_BOOT"]
  volumes:
    - name: data-volume
      hostPath:
        path: /data
        type: Directory

Four separate red flags in one small spec: privileged: true hands the container root-equivalent access to the host, runAsUser: 0 runs it as root inside the container too, CAP_SYS_BOOT is a Linux capability a workload container has essentially no legitimate reason to hold, and the hostPath volume mounts a piece of the node's own filesystem straight into the container. Any one of those is worth a second look on its own. All four together is a container that barely needs the host machine's permission to do whatever it wants to it.

How: if the PSP admission controller was switched on and a matching policy existed, every one of those settings got compared against it before the Pod was allowed to run.

Why it mattered: RBAC never reads that far into a Pod spec. It answers "can this identity create a Pod", full stop, it has no opinion on what that Pod is actually asking for. Something has to open the spec and judge the content, and for the better part of Kubernetes' early years, PSP was that something.

So a permissive Pod spec isn't automatically a problem. RBAC and admission control are two separate jobs, and a cluster that only checks one of them has a hole shaped exactly like the other.

Turning it on wasn't the hard part

PSP was one plugin among many on the API server's --enable-admission-plugins flag:

ExecStart=/usr/local/bin/kube-apiserver \
  --advertise-address=${INTERNAL_IP} \
  --authorization-mode=Node,RBAC \
  --enable-admission-plugins=PodSecurityPolicy \
  --allow-privileged=true \
  ...

One flag, and from that moment every Pod creation request had to clear the PSP admission controller: it looked at every PodSecurityPolicy object in the cluster, checked whether the incoming Pod satisfied at least one of them, and rejected anything that didn't. Flip a flag, get a gate. That part was genuinely simple.

Writing a policy that actually blocks something

The bare minimum PSP that stops the privileged Pod above:

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: example-psp
spec:
  privileged: false

That single field is enough, any Pod with privileged: true now gets rejected outright. A more realistic policy layers on several more checks at once: no running as root, a mandatory dropped capability, a default capability granted automatically, and a locked-down list of volume types:

apiVersion: policy/v1beta1
kind: PodSecurityPolicy
metadata:
  name: example-psp
spec:
  privileged: false
  seLinux:
    rule: RunAsAny
  supplementalGroups:
    rule: RunAsAny
  runAsUser:
    rule: MustRunAsNonRoot
  requiredDropCapabilities:
    - CAP_SYS_BOOT
  defaultAddCapabilities:
    - CAP_SYS_TIME
  volumes:
    - persistentVolumeClaim

Notice that last block, volumes: [persistentVolumeClaim]. If it's the only entry, the earlier Pod's hostPath mount is rejected on volume type alone, on top of everything else it was already failing. And defaultAddCapabilities is worth staring at for a second: that's not just a check, it's a mutation. A PSP could silently add a capability to a Pod that never asked for one. I'll come back to why that single feature was a bigger deal than it sounds.

The part that actually broke clusters: PSPs needed RBAC to work at all

Here's the design decision that did PSP in. A policy object existing in the cluster wasn't enough on its own, a Pod had to be allowed to use it, and that permission was granted the same way every other Kubernetes permission is: RBAC, via the Pod's Service Account.

apiVersion: rbac.authorization.k8s.io/v1
kind: Role
metadata:
  name: psp-example-role
rules:
  - apiGroups: ["policy"]
    resources: ["podsecuritypolicies"]
    resourceNames: ["example-psp"]
    verbs: ["use"]
---
apiVersion: rbac.authorization.k8s.io/v1
kind: RoleBinding
metadata:
  name: psp-example-rolebinding
subjects:
  - kind: ServiceAccount
    name: default
    namespace: default
roleRef:
  kind: Role
  name: psp-example-role
  apiGroup: rbac.authorization.k8s.io

No Role, no RoleBinding, no use verb on that exact PSP by name, and the Service Account submitting the Pod has no PSP it's allowed to use at all. Not "falls back to a lenient default", not "gets a sensible baseline". Nothing. And Kubernetes' actual default Service Account, called simply default, gets attached to a Pod automatically if you never specify one.

The footgun in one sentence Enable the PSP admission controller with even one namespace's Service Accounts unbound to any policy, and every single Pod creation in that namespace starts failing, Deployments included, with an admission error that gives you almost no clue what actually went wrong.
01
Pod request arrives

Deployment, kubectl run, doesn't matter, it carries a Service Account

02
PSP admission controller checks

Which PSPs is this Service Account allowed to use, via RBAC?

03
No bound PSP found

Not "allow with defaults". Reject, unconditionally

04
Every Pod in that namespace fails

Including ones from Deployments and ReplicaSets, which retry and fail again

This is what "enabled the admission plugin, forgot the RoleBinding" actually looks like in practice.

I want to be fair to PSP here, because I don't think this was carelessness on the design's part, it's what you'd build if you wanted every policy grant to be explicit and auditable through the same RBAC system as everything else. It's a coherent idea. It's also exactly the kind of coherent idea that quietly takes a namespace offline the first time someone enables the admission plugin on a cluster that's been running fine without it, because the Roles and RoleBindings were never part of that cluster's mental model until that moment.

One more thing that never sat right with me Pod Security Admission and the Pod Security Standards that replaced PSP deliberately dropped the mutating behaviour, defaultAddCapabilities and friends. The new system only validates, it never edits your Pod for you. After the trap above, I understand exactly why: a policy silently changing what you asked for, on top of a system where forgetting one RBAC binding kills every Pod in a namespace, was two footguns stacked on each other.

Why 1.25 was the end of the road

PSP was marked deprecated back in Kubernetes 1.21 and fully removed in 1.25, and the RBAC-binding trap above was a genuine part of why, but it wasn't the only reason. A cluster could also have several PSPs that each partially matched an incoming Pod, and which one actually applied wasn't always obvious from reading the objects, you had to reason about admission order across an unordered set. Predicting the effective policy on a live cluster sometimes meant more archaeology than it should. Between that and the RBAC trap, the Kubernetes project decided the whole model needed replacing rather than patching, and KEP-2579 is the proposal that became what runs today.

Pod Security Admission: same job, deliberately less clever

Pod Security Admission is a built-in, validation-only admission controller that reads a label straight off the namespace. No RBAC binding, no separate policy objects to create.

I put enforce=restricted on a namespace and watched a root container get rejected in Topic 27, but that was one mode against one profile. There are three of each, and the combination is the whole system:

Privileged

No restrictions at all. Reserved for system and infrastructure namespaces that genuinely need the run of the node.

Baseline

Blocks the well-known container breakout paths, privileged mode, most host namespaces, dangerous capabilities, while staying compatible with almost any normal workload.

Restricted

Current pod-hardening best practice, enforced. Non-root, no privilege escalation, capabilities dropped, seccomp required. Strict enough that some images need adjusting to pass.

Baseline is the one I'd actually default a general-purpose namespace to. Restricted is where you land once you've deliberately hardened the workload to match it.

And the mode decides what happens on a violation, independent of which profile you picked:

The three Pod Security Admission modes
ModeOn a violationUse it when
enforcePod creation is rejected outrightYou're confident the namespace's workloads already comply
warnPod is created, user gets a warning at apply timeYou're rolling a stricter profile out and want visibility, not breakage
auditPod is created, violation goes to the audit log onlyYou want a paper trail without bothering anyone applying manifests
A single namespace can run more than one mode at once, each against its own profile, which is exactly what the lab below does.

Hands-on: three namespaces, three postures, one label command each

The whole point of PSA is that this is the entire configuration surface, a label, nothing else to create or bind:

kubectl label ns payroll pod-security.kubernetes.io/enforce=restricted
kubectl label ns hr pod-security.kubernetes.io/enforce=baseline
kubectl label ns dev pod-security.kubernetes.io/warn=restricted

payroll is now as locked down as this system gets: anything that doesn't meet restricted is refused outright. hr gets the sane middle ground most day-to-day workloads already satisfy. dev gets the strict profile too, but in observation mode only, nothing is blocked, people just start seeing the warnings before enforcement ever shows up.

Push the same non-compliant Pod, root user, no capability drop, at both payroll and dev:

kubectl apply -n payroll -f rootpod.yaml
Error from server (Forbidden): error when creating "rootpod.yaml": pods "rootpod" is forbidden: violates PodSecurity "restricted:latest": > runAsNonRoot != true, allowPrivilegeEscalation != false
kubectl apply -n dev -f rootpod.yaml
Warning: would violate PodSecurity "restricted:latest": > runAsNonRoot != true, allowPrivilegeEscalation != false pod/rootpod created
Identical Pod, identical profile, two completely different outcomes, because the mode and the profile are set independently.

That's the property I actually wanted out of this lab: I can point dev at the strictest profile the project ships and let people see exactly what would break, for weeks if I want, without a single Pod actually failing. Flip that same namespace to enforce once the warnings dry up and the migration is done with zero surprise.

Checking the config is actually applied

kubectl get ns payroll --show-labels
# NAME      STATUS   AGE   LABELS
# payroll   Active   4h    pod-security.kubernetes.io/enforce=restricted

kubectl exec -n kube-system kube-apiserver-controlplane -it \
  -- kube-apiserver -h | grep enable-admission-plugins

That second command is worth running once on any cluster you're assessing, whether you're defending it or testing it. Pod Security Admission ships enabled by default on any current Kubernetes version, so the interesting question isn't "is it on", it's "which profile, on which namespace, in which mode", and the label on the namespace object is the entire answer.

Where PSA stops, and third-party tools pick up

PSA is deliberately not extensible, three profiles, three modes, that's the whole API surface. It won't let you write a rule like "images must come from this registry" (I actually built that exact rule with a custom webhook in Topic 28) or anything else organisation-specific. For that, Kyverno, OPA Gatekeeper and tools like K-Rail exist precisely to cover the gap, policy as YAML or Rego, running alongside PSA rather than replacing it.

An unpopular opinion about PSP

Genuinely, I think PSA is a better design than the thing it replaced, and that's not a popular thing to say about a tool with fewer knobs. Fewer knobs turned out to be the point. PSP let you build something extremely precise and also extremely easy to misconfigure into a cluster-wide outage. PSA trades that precision for something you can reason about by reading one label on one namespace.

What I'm still not sure about: how much real-world precision that trade actually costs on a cluster with genuinely unusual workloads, the kind that need one specific capability PSA's fixed profiles don't have a lever for. I haven't hit that wall myself yet, homelab workloads are forgiving, but I can see how a team running something odd in production would reach for Kyverno within a month of adopting PSA, not because PSA is wrong, just because "three fixed profiles" was never going to cover every real cluster.

Next topic I'm going after something that doesn't need a misconfigured Pod at all: how Kubernetes Secrets actually leak in a cluster that's already running fine, and it's rarely RBAC's fault.

References

FAQ

What were Pod Security Policies (PSPs)?

A cluster-level admission control object that validated and could mutate Pod specs, blocking things like privileged containers, root users, or disallowed volume types. They were removed from Kubernetes in version 1.25 after being deprecated since 1.21.

Why were Pod Security Policies removed from Kubernetes?

PSPs were bound to Pods indirectly through RBAC Roles on Service Accounts, which made it easy to enable the admission controller with no policies wired up and lock every Pod creation in the cluster, or to end up with an unpredictable mix of policies where it was unclear which one actually applied.

What replaced Pod Security Policies?

Pod Security Admission (PSA), a built-in, validation-only admission controller based on KEP-2579. Instead of binding policies through RBAC, it reads a label directly off the namespace and checks every Pod in it against one of three fixed Pod Security Standards profiles.

What are the three Pod Security Standards profiles?

Privileged, which applies no restrictions at all; Baseline, which blocks the most common ways to break out of a container while staying compatible with most workloads; and Restricted, which enforces current pod hardening best practice and is the strictest of the three.

What is the difference between enforce, audit and warn modes in Pod Security Admission?

Enforce rejects a Pod that violates the profile. Audit allows it through but records the violation in the audit log. Warn allows it through and shows the user a warning at apply time. A namespace can run more than one mode at once, each pointed at a different profile.