PodSecurityPolicy, the old mechanism for restricting pods (banning privileged containers, forcing a non-root user), was permanently removed from Kubernetes in version 1.25, after years of deprecation. Pod Security Admission replaces it with a radically simpler mechanism: a label set on a namespace, no CRD, no external webhook to deploy.
Three profiles, a strict hierarchy
Pod Security Standards defines three profiles of increasing restriction. privileged imposes no restriction at all, the equivalent of no control. baseline blocks the most obvious privilege escalations (privileged containers, direct access to the host network or PID namespace) without getting in the way of most standard workloads. restricted goes much further: mandatory non-root user, an explicitly set seccomp profile, Linux capabilities trimmed to the strict minimum.
apiVersion: v1
kind: Namespace
metadata:
name: production
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/audit: restricted
That label is enough: no extra controller to install, the API server enforces the restriction natively the moment a pod gets created in that namespace.
Three modes, to migrate without breaking everything at once
Each profile can be applied under three distinct, stackable modes: enforce actually rejects non-compliant pods, audit logs a violation without blocking, warn shows a warning on the kubectl client side, also without blocking. That distinction enables a gradual migration: setting a namespace to audit: restricted reveals, with zero risk of breakage, every pod that would fail if enforce were turned on.
metadata:
labels:
# Observe the real impact before enforcing anything
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
# enforce stays absent: nothing gets blocked yet
# Violations in audit mode show up in the API server's
# logs, without ever blocking a deployment
kubectl logs -n kube-system -l component=kube-apiserver | grep "would violate"
What restricted breaks most often
The restricted profile requires runAsNonRoot: true, which immediately fails on any image built without an explicit non-root user, including many legacy base images that still run as root by default. It also requires capabilities.drop: ["ALL"] and, on Linux, a seccompProfile.type explicitly set to RuntimeDefault or Localhost — the absence of a profile is rejected just like Unconfined. That last check is an easy one to miss: a manifest that works perfectly under baseline gets rejected under restricted over a field nobody ever had to write before.
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
seccompProfile:
# Leave this out and restricted rejects the pod
type: RuntimeDefault
capabilities:
drop: ["ALL"]
readOnlyRootFilesystem: true usually gets mentioned in the same breath. It is a sound hardening practice, but it belongs to none of the three profiles: no Pod Security Admission check looks at it, and a pod writing to its root filesystem passes restricted without complaint. Hardening images on that front is worthwhile; doing it in the belief that it prepares a move to restricted means working on the wrong control and discovering seccompProfile on switchover day.
These requirements aren’t arbitrary: each closes a documented privilege-escalation path, but they assume images built accordingly, a prerequisite often overlooked until restricted gets enabled on a namespace hosting older images.
Two traps in the mechanism itself
The first one gets paid in diagnostic time, because it fails nowhere you would think to look. enforce is not applied to workload resources. The documentation says it in one sentence: audit and warning modes are applied to the workload resources, “however, enforce mode is not applied to workload resources, only to the resulting pod objects”. So kubectl apply of a non-compliant Deployment succeeds, exit code 0, message deployment.apps/... created. The Deployment then sits at 0/3, and the rejection does exist, but two levels below the command that just returned 0.
# The command that "worked"
kubectl apply -f deployment.yaml
# deployment.apps/api created (exit code 0)
kubectl get deploy api
# READY 0/3
# The rejection is here, not in the output above
kubectl describe rs -l app=api
kubectl get events --field-selector reason=FailedCreate
That is the real reason to set warn alongside enforce, and not only before it: warn does apply to the workload resource, so the warning surfaces in the terminal at kubectl apply time, where the enforce rejection never will.
The second trap is a missing version floor. The pod-security.kubernetes.io/<MODE>-version label is optional: it pins the policy to the version that shipped with a given Kubernetes minor version. Left out, the admission controller’s default applies, and that documented default is latest. A namespace labelled enforce: restricted with no enforce-version therefore follows the cluster’s current definition of restricted: the policy hardens itself on upgrade, with no manifest having moved.
metadata:
labels:
pod-security.kubernetes.io/enforce: restricted
# Without this line, the policy follows the cluster.
# With it, it stays the one from a known minor version.
pod-security.kubernetes.io/enforce-version: v1.37
What the label does not protect
Pod Security Admission answers a single question: what a pod is allowed to ask of its runtime. The namespace carrying the label is not thereby a security boundary, and the Kubernetes multi-tenancy documentation puts it plainly — the namespace isolation model “requires configuration of several other Kubernetes resources, networking plugins, and adherence to security best practices” to properly isolate workloads.
Five distinct mechanisms, none of them covered by the label:
- The network. By default, all pods in a cluster are allowed to talk to each other and all traffic is unencrypted; the cluster DNS service allows lookups across all namespaces by default. You need a NetworkPolicy, and a CNI plugin that implements it — otherwise the resource is ignored with no error. Even correctly written, it lets through the
kubectl port-forwardtunnel, which does not take the network path it controls. - Identity. Every namespace gets a
defaultServiceAccount on creation, a pod naming none gets it, and mounting its API credentials is the default behaviour: you opt out explicitly withautomountServiceAccountToken: false. What that token then allows is RBAC’s business, not the pod profile’s. - Resources. Nothing in the three profiles bounds a container’s CPU or memory consumption. That is what ResourceQuota and LimitRange are for.
- The kernel and the nodes. The profiles shrink a container’s attack surface; they do not take it out of the shared kernel. Even with nodes dedicated to a tenant, the kubelet and the API server remain shared services, and the documentation warns that an attacker escaping a container can move laterally within the cluster. The answers at that level are sandboxing, seccomp, AppArmor or SELinux.
- What isn’t namespaced. Namespace isolation “doesn’t apply to Kubernetes resources that can’t be namespaced, such as Custom Resource Definitions, Storage Classes, and Webhooks”. A tenant who can create a CRD creates it for the whole cluster: that is the tipping point toward a virtual control plane or separate clusters.
The label remains one of the best deals in the cluster: one line of YAML for a native control, with nothing to install. It only becomes misleading the moment you take it for the answer rather than the first of five.
Takeaway
Pod Security Admission replaces PodSecurityPolicy with a native, radically simpler mechanism: a namespace label, three profiles (privileged, baseline, restricted), three modes (enforce, audit, warn) that let you observe the impact before blocking anything. The restricted profile imposes real constraints (non-root, explicit seccomp, dropped capabilities) that often fail on older images, which makes a prior audit pass essential before any enforce in production, one of the hardening steps that comes with a serious Kubernetes migration.