Back to blog

Security

PodSecurityPolicy replacement: Pod Security Admission

A practical PodSecurityPolicy replacement guide: roll out Pod Security Admission in stages, pin policy versions, and avoid the traps that break workloads.

August 13, 2026Platform Engineering9 min read

If you still think of Pod Security Admission as the cut-down replacement for PodSecurityPolicy, that is exactly the mindset that makes rollouts harder than they need to be. It is less powerful. That is also why it is practical. You get three fixed profiles, three modes, a handful of labels, and an admission controller that is already in the API server. No custom policy language, no mutation, no controller to install, and far fewer ways to surprise yourself mid-change.

The trick is to use that simplicity properly. A safe rollout is not “turn on restricted everywhere and hope for the best”. It is version-pinned labels, audit and warn first, then namespace-by-namespace enforcement once you have seen what will break. Done that way, you can move from the old PSP world to a built-in control that teams will actually leave enabled.

What replaced PodSecurityPolicy

PodSecurityPolicy was deprecated in Kubernetes v1.21 and removed in v1.25. Pod Security Admission became generally available in the same release, which matters because there is nothing extra to deploy: if your cluster is current enough, the mechanism is already there.

The model is deliberately small:

  • Profiles: privileged, baseline, restricted
  • Modes: enforce, audit, warn
  • Version pinning: pod-security.kubernetes.io/<mode>-version, pinned to a Kubernetes minor version or latest

Those labels live on namespaces:

# pin policy to the minor version your cluster is running
pss="$(kubectl version -o json | jq -r '.serverVersion.gitVersion' | cut -d. -f1,2)"

kubectl label ns payments \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/enforce-version="$pss"

That simplicity is the point. PSP mixed policy with RBAC, allowed mutation, and had enough moving parts that many teams either avoided it or never quite trusted what a change would do. PSA is narrower. It sets a floor.

The mental model: three profiles, three modes, one version pin

You can think of PSA as a matrix:

Mode What happens
enforce Rejects a non-compliant pod creation request
audit Allows it, but records an audit annotation
warn Allows it, but shows a warning to the caller

And the profiles are ordered from least to most strict:

Profile What it is for
privileged System namespaces and workloads that genuinely need host access or privileged behaviour
baseline A sensible minimum floor for ordinary application namespaces
restricted Current pod hardening best practice

For most application namespaces, the safe starting pattern is:

kubectl label --overwrite ns payments \
  pod-security.kubernetes.io/enforce=baseline \
  pod-security.kubernetes.io/enforce-version="$pss" \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/warn-version="$pss" \
  pod-security.kubernetes.io/audit=restricted \
  pod-security.kubernetes.io/audit-version="$pss"

That gives you a firm minimum bar in enforce, while warn and audit show you what stands between the namespace and restricted.

The version pin is not optional bureaucracy. If you leave a namespace on latest, any cluster upgrade can quietly tighten policy under your workloads. Pinning to the minor version you are actually running means policy changes happen when you decide to move the label.

What restricted actually checks

The easiest way to lose credibility on this topic is to hand-wave the restricted profile and get one detail wrong. Several of the checklists floating around in other posts are simply false — especially around runAsUser and seccomp.

Here is the practical checklist, straight from the Pod Security Standards:

Control What restricted expects
Volume types Only configMap, csi, downwardAPI, emptyDir, ephemeral, persistentVolumeClaim, projected, secret
Privilege escalation allowPrivilegeEscalation: false on every container, including init and ephemeral containers
Non-root runAsNonRoot: true at pod level or per container
UID runAsUser does not need to be set, but if it is, it cannot be 0
Capabilities Drop ALL; the only allowed add is NET_BIND_SERVICE
Seccomp Must be explicitly RuntimeDefault or Localhost; leaving it unset is a violation under restricted

Two of those rows are where the bad checklists go wrong. runAsUser is not required — the policy forbids UID 0, it does not make you hard-code some other UID just to satisfy the admission check. And seccomp cuts the other way: under baseline an unset profile is fine, but under restricted it is a violation in its own right, which is one of the reasons plain demo manifests still bounce when teams first try this.

The rollout that does not break production

The safest PSA rollout starts with a dry run, not an enforcement change — the official migration guide is built around the same idea.

First, test label application server-side across all namespaces:

kubectl label --dry-run=server --overwrite ns --all \
  pod-security.kubernetes.io/enforce=baseline

That does not change anything. It asks the API server what violations it would report if the label were applied.

Then stage audit and warn cluster-wide:

kubectl label --overwrite ns --all \
  pod-security.kubernetes.io/audit=baseline \
  pod-security.kubernetes.io/warn=baseline

Or, if your end state is more ambitious, go straight to the pattern that tends to work well in practice:

  • enforce=baseline
  • warn=restricted
  • audit=restricted

That gives you signal without immediately creating an outage because some forgotten sidecar, root-running image, or hostPath mount was hiding in a namespace nobody had looked at for months.

You can also find namespaces that have no PSA labels yet:

kubectl get ns --selector='!pod-security.kubernetes.io/enforce'

Once the warnings are clean for a namespace, pin the version and turn on enforce there. Namespace by namespace is slower than one giant switch, but slower is exactly what you want when admission control is involved.

While the rollout is in flight, the API server’s own metrics — pod_security_evaluations_total, pod_security_errors_total and pod_security_exemptions_total — are worth scraping. They tell you whether evaluation is happening where you think it is, and whether exemptions are starting to sprawl.

What a real rejection looks like

The good news about PSA failures is that the error message is the fix list.

Apply a plain pod into a namespace with enforce=restricted and you get something like this:

Error from server (Forbidden): pods "nginx" is forbidden: violates PodSecurity "restricted:latest":
allowPrivilegeEscalation != false (container "nginx" must set securityContext.allowPrivilegeEscalation=false),
unrestricted capabilities (container "nginx" must set securityContext.capabilities.drop=["ALL"]),
runAsNonRoot != true (pod or container "nginx" must set securityContext.runAsNonRoot=true),
seccompProfile (pod or container "nginx" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")

That message is unusually helpful by Kubernetes standards. Every violation names the exact field to set and the value it wants, so you can work through it like a checklist.

A minimal compliant pod looks like this:

apiVersion: v1
kind: Pod
metadata:
  name: nginx
spec:
  securityContext:
    runAsNonRoot: true
    seccompProfile:
      type: RuntimeDefault
  containers:
    - name: nginx
      image: nginxinc/nginx-unprivileged:stable
      ports:
        - containerPort: 8080
      securityContext:
        allowPrivilegeEscalation: false
        capabilities:
          drop:
            - ALL

Two quiet fixes are hiding in that example: the container-level security context, and the image itself. A lot of “hello world” manifests still assume a root-running image on port 80, which is a fine way to discover that restricted is not interested in your tutorial shortcuts.

The trap most teams hit once: Deployments apply, pods do not

This is the operational detail that makes or breaks a rollout plan.

warn and audit evaluate workload resources such as Deployment pod templates. enforce does not. enforce applies only to the resulting pod objects.

That means this can happen:

  1. You apply a Deployment
  2. Kubernetes accepts the Deployment
  3. The ReplicaSet tries to create pods
  4. The pods are rejected by PSA
  5. You now have a deployment that looks “applied” but never becomes healthy

When that happens, the signal is in the ReplicaSet and the events, not in the initial kubectl apply output:

kubectl get rs -n payments
kubectl get events -n payments --sort-by=.metadata.creationTimestamp

If you remember only one rollout lesson from this post, make it this one. Admission on pod creation and admission on workload templates are not symmetrical.

The namespaces that should not be restricted

kube-system is the obvious example. CNIs, CSIs, node agents, and static pods routinely need things that baseline or restricted will reject: hostPath, hostNetwork, privileged containers, or other host-level access.

Be explicit about that. Label those namespaces privileged so the exception is documented in the cluster state rather than living as folklore.

Istio adds another common surprise — relevant if you run a mesh for zero-trust networking. With sidecar injection but without Istio CNI, the injected istio-init container needs NET_ADMIN and NET_RAW to set up traffic redirection. That is enough to fail baseline, not just restricted. The usual fix is to enable the CNI node agent and keep istio-system itself privileged.

Why version pinning matters more than it looks

A pinned PSA version is an operational control, not a documentation detail.

Kubernetes v1.34 added a baseline control that blocks the host field in httpGet and tcpSocket probes and lifecycle hooks. The reason is sensible: that field can be abused as an SSRF path through the kubelet. The operational consequence is that a cluster upgrade can start rejecting manifests that were fine the day before — if your labels track latest.

With version pinning, you choose when that tighter rule lands. With $pss still set to the minor version you were running before the upgrade:

kubectl label --overwrite ns payments \
  pod-security.kubernetes.io/enforce-version="$pss" \
  pod-security.kubernetes.io/warn-version="$pss" \
  pod-security.kubernetes.io/audit-version="$pss"

That makes upgrades much less dramatic. Upgrade the cluster first. Move the policy version when you are ready to deal with the findings.

The RBAC hole nobody should ignore

PSA policy lives on namespace labels. That means anyone who can update namespace labels can also weaken or remove enforcement.

Review that RBAC carefully. It is easy to focus on pod-creation rights and forget that update on namespaces is effectively policy-admin access.

There is a more central exemption mechanism through the API server AdmissionConfiguration, where you can exempt namespaces, usernames, or runtime classes. That is useful on self-managed control planes. On managed services such as EKS, GKE, and AKS, you usually do not get to edit that file at all, which means namespace labels are the real policy surface.

Where Pod Security Admission stops

PSA is the built-in floor. That is its job, and it is a good one.

There is no mutation, so no PSP-style defaulting is waiting to save a sloppy manifest. There is no custom rule language, and no way to express fine-grained per-workload exceptions inside one namespace. Once you want those things, you are into policy engines such as Kyverno or OPA Gatekeeper.

That is not a weakness so much as a division of labour. Use PSA to guarantee the cluster-wide minimum. Use a policy engine above it when you need mutation, richer exceptions, or application-specific constraints.

PodSecurityPolicy is gone. The sensible replacement is not to rebuild PSP in another tool and pretend nothing changed. It is to accept the smaller built-in model for what it is: predictable, cheap to operate, and strong enough to become the default floor across a cluster. That is a better trade than a perfect policy system nobody trusts enough to enable.