PodSecurityPolicy was removed in Kubernetes 1.25. If your reference material still teaches PSP, it is describing a resource that no longer exists. Its replacement is simpler, built in, and enforced by namespace labels.
Topic 1: Why PSP Died
PodSecurityPolicy was deprecated in 1.21 and removed in 1.25. Its problems were structural:
- Authorisation by RBAC on the policy object — which PSP applied depended on which ones the creating user could
use, and when several matched, an ordering algorithm nobody could predict picked one. - Mutation. PSPs could silently modify pods, so what you applied was not what ran.
- Impossible to roll out safely. Enabling the admission plugin with no policies blocked every pod in the cluster.
- It applied to the pod creator, but pods are usually created by controllers — so the effective identity was a ServiceAccount, not a human, and reasoning about it was near-impossible.
Pod Security Admission (PSA) replaced it in 1.25: built into the API server, configured with namespace labels, no mutation, and completely predictable.
Topic 2: The Three Standards
PSA implements the Pod Security Standards — three fixed profiles, not custom policies.
| Profile | Blocks | For |
|---|---|---|
| privileged | Nothing | System components, CNI, CSI |
| baseline | Known privilege escalations | Most application workloads |
| restricted | Baseline + enforces hardening | Anything you can make work |
baseline rejects: privileged: true, host namespaces (hostNetwork, hostPID, hostIPC), hostPath volumes, host ports, adding capabilities beyond a small allowed set, /proc mount tricks, unsafe sysctls, and non-default AppArmor/SELinux/seccomp overrides.
restricted additionally requires:
runAsNonRoot: trueallowPrivilegeEscalation: falsecapabilities.drop: ["ALL"]seccompProfile.type: RuntimeDefault(orLocalhost)- Only projected/ephemeral volume types
Topic 3: Applying It
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.36
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/warn: restricted
Three modes, and using all three is the point:
| Mode | Effect |
|---|---|
enforce | Reject violating pods |
audit | Allow, but record a violation in the audit log |
warn | Allow, but return a warning to the user on apply |
The safe adoption path uses them together:
pod-security.kubernetes.io/enforce: baseline # what you enforce TODAY
pod-security.kubernetes.io/warn: restricted # what you WILL enforce
pod-security.kubernetes.io/audit: restricted
Teams see warnings for months while you collect audit data, and nothing breaks. When the warnings stop, raise enforce to restricted.
Pin enforce-version. Without it, the profile tracks the cluster version, so upgrading Kubernetes can silently tighten policy and start rejecting pods that were fine yesterday.
kubectl label namespace payments pod-security.kubernetes.io/enforce=restricted
kubectl label namespace payments pod-security.kubernetes.io/warn=restricted
# Dry-run the whole namespace against a profile BEFORE enforcing
kubectl label --dry-run=server --overwrite ns payments \
pod-security.kubernetes.io/enforce=restricted
# Warning: existing pods in namespace "payments" violate the new PodSecurity level
# "restricted:latest": api-7d9f (container "api" must set securityContext...)
That server-side dry run is the single most useful command here — it lists every existing pod that would break, without changing anything.
PSA applies at pod creation only. Running pods are never evicted by a label change, and a Deployment whose pods now violate policy keeps running until its next rollout — at which point it fails to create pods, and the rollout stalls. That delay between labelling and breaking is worth planning for.
Topic 4: A Compliant Pod Spec
apiVersion: apps/v1
kind: Deployment
metadata:
name: api
spec:
template:
spec:
serviceAccountName: api-sa
automountServiceAccountToken: false
securityContext: # POD level
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
fsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
image: registry.example.com/api:1.4.2@sha256:abc123...
securityContext: # CONTAINER level — wins over pod level
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
runAsNonRoot: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: cache
mountPath: /var/cache
volumes:
- name: tmp
emptyDir: {}
- name: cache
emptyDir: {}
Field by field, and why:
runAsNonRoot: true + runAsUser — a container process running as UID 0 that escapes the container is root on the node. Note the image must actually work as a non-root user; if its USER is root and you set runAsNonRoot, the container fails to start with CreateContainerConfigError.
allowPrivilegeEscalation: false — sets no_new_privs, so setuid binaries cannot gain privilege. This blocks a whole class of escalation inside the container.
readOnlyRootFilesystem: true — an attacker cannot write a payload to disk. Requires mounting emptyDir at every path the app genuinely writes to (/tmp almost always).
capabilities.drop: ["ALL"] — containers get a default capability set including CHOWN, SETUID, NET_RAW and others. NET_RAW alone permits ARP spoofing from a compromised pod. Drop everything and add back only what fails:
capabilities:
drop: ["ALL"]
add: ["NET_BIND_SERVICE"] # only to bind ports below 1024
Better still, do not bind a low port — listen on 8080 and let the Service map 80 to it.
seccompProfile: RuntimeDefault — restricts the syscalls available to the container to the runtime’s curated set, which blocks many kernel exploits. It is the default for restricted and has near-zero compatibility cost.
Pin by digest (@sha256:...) for anything security-sensitive: a tag can be repointed at different content, a digest cannot.
Topic 5: What PSA Cannot Do, and What Fills the Gap
PSA enforces three fixed profiles. It cannot express:
- “Images must come from our registry"
- "Every pod must have resource limits"
- "Every workload must carry a
teamlabel" - "No
:latesttags”
For those you need a policy engine as a validating admission webhook:
| Tool | Language | Note |
|---|---|---|
| Kyverno | YAML | Kubernetes-native; can also mutate and generate. Easiest to adopt |
| OPA Gatekeeper | Rego | Very expressive; steeper learning curve |
| Validating Admission Policy | CEL, built-in, GA in 1.30 | No webhook to run — see below |
# Kyverno: require resource limits
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-limits
spec:
validationFailureAction: Enforce
rules:
- name: check-limits
match:
any:
- resources: { kinds: [Pod] }
validate:
message: "Every container must set resource limits."
pattern:
spec:
containers:
- resources:
limits:
memory: "?*"
cpu: "?*"
ValidatingAdmissionPolicy (GA 1.30) is worth knowing because it removes the webhook entirely — the rule is evaluated in-process by the API server using CEL:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-limits
spec:
matchConstraints:
resourceRules:
- apiGroups: [""], apiVersions: ["v1"], operations: ["CREATE","UPDATE"], resources: ["pods"]
validations:
- expression: "object.spec.containers.all(c, has(c.resources.limits))"
message: "all containers must set resource limits"
No webhook means no availability risk from the policy engine itself — which, as the next lesson covers, is a real failure mode.
Topic 6: Hardening Beyond the Pod Spec
Images:
- Distroless or
scratchbases — no shell means no shell for an attacker either. - Multi-stage builds so build tools never ship.
- Scan in CI (Trivy, Grype) and fail the build on critical CVEs.
- Sign and verify (
cosign, Sigstore) with an admission policy that rejects unsigned images.
Runtime:
NET_RAWdropped stops ARP spoofing and most packet crafting.- Block the metadata endpoint (
169.254.169.254) in NetworkPolicy — see the NetworkPolicy lesson. - Falco or Tetragon for runtime detection of unexpected syscalls, exec into containers, or writes to sensitive paths.
Supply chain: generate an SBOM per image, and track which clusters run which digests. When the next widely-exploited CVE lands, the question “are we running it?” should take minutes, not days.
A useful audit:
# Privileged containers anywhere
kubectl get pods -A -o json | jq -r '.items[] |
select(.spec.containers[]?.securityContext.privileged == true) |
"\(.metadata.namespace)/\(.metadata.name)"'
# Pods running as root (or not explicitly non-root)
kubectl get pods -A -o json | jq -r '.items[] |
select((.spec.securityContext.runAsNonRoot // false) != true) |
"\(.metadata.namespace)/\(.metadata.name)"'
# Which namespaces have no PSA labels at all
kubectl get ns -o json | jq -r '.items[] |
select((.metadata.labels // {}) | has("pod-security.kubernetes.io/enforce") | not) |
.metadata.name'
Try it yourself: Label a namespace enforce=restricted and apply a stock nginx Deployment. It will be rejected — nginx runs as root and binds port 80. Work through the rejection message until it is admitted (hint: nginxinc/nginx-unprivileged exists precisely for this).
Common mistake: Applying enforce=restricted to a namespace with running workloads and assuming nothing happened because nothing broke. Existing pods are untouched; the breakage arrives at the next rollout, possibly weeks later, in an unrelated change. Use the server-side dry run first, and set warn before enforce.