
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.
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.
Deployment, kubectl run, doesn't matter, it carries a Service Account
Which PSPs is this Service Account allowed to use, via RBAC?
Not "allow with defaults". Reject, unconditionally
Including ones from Deployments and ReplicaSets, which retry and fail again
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.
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:
No restrictions at all. Reserved for system and infrastructure namespaces that genuinely need the run of the node.
Blocks the well-known container breakout paths, privileged mode, most host namespaces, dangerous capabilities, while staying compatible with almost any normal workload.
Current pod-hardening best practice, enforced. Non-root, no privilege escalation, capabilities dropped, seccomp required. Strict enough that some images need adjusting to pass.
And the mode decides what happens on a violation, independent of which profile you picked:
| Mode | On a violation | Use it when |
|---|---|---|
| enforce | Pod creation is rejected outright | You're confident the namespace's workloads already comply |
| warn | Pod is created, user gets a warning at apply time | You're rolling a stricter profile out and want visibility, not breakage |
| audit | Pod is created, violation goes to the audit log only | You want a paper trail without bothering anyone applying manifests |
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:
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
- Kubernetes docs: Pod Security Policies (deprecated)
- Kubernetes docs: Pod Security Admission
- Kubernetes docs: Pod Security Standards
- KEP-2579: PSP Replacement
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.
Related reading
- Topic 27: Admission Controllers (RBAC's Blind Spot) (the first look at PodSecurity in action)
- Topic 28: Building a Custom Admission Webhook (the rules PSA can't express)
- Topic 19: Kubernetes Least Privilege (the principle both PSP and PSA exist to enforce)
- Browse the whole Kubernetes Journey