Pod Security Standards & admission
Baseline, restricted, and OPA/Gatekeeper.
Spin up a fresh namespace and it trusts everything. The default Pod Security Standard is privileged, which is a polite way of saying no rules at all: a pod there can run as root, mount the host's filesystem, or turn on full privileged mode, and the cluster won't say a word. Admission control is how you close that gap. Think of a bouncer on the door of a club. Before anyone gets into the room they're checked against a guest list, and whoever doesn't match gets turned away at the threshold, not dragged back out later. Pod Security Admission, or PSA, is that bouncer for pods. It's built into the API server and went stable in Kubernetes v1.25, the same release that finally deleted the old PodSecurityPolicy (PSP). Every pod that tries to enter a namespace gets graded against one of three Pod Security Standards (PSS) before it's let in.
There are three standards, and they stack from wide open to locked down. privileged is the open profile you keep for trusted infrastructure that genuinely needs the host underneath it. baseline blocks the well-worn escape routes attackers reach for first: host path mounts, host networking, sharing the host's process (PID) and inter-process (IPC) namespaces, privileged mode, and a short list of dangerous Linux capabilities. restricted is the hardened floor, and it's the one you want almost everywhere. It demands that a pod run as non-root, never escalate privileges, drop every capability, set a seccomp profile, and use only the safe volume types. A quick word on seccomp, which stands for secure computing mode: it's an allow-list of the system calls a container may make down to the Linux kernel, the same way you might set a phone to only ring for numbers already in your contacts. Everything else is refused. And you install nothing to get any of this. PSA reads plain labels on the namespace, so there's no custom resource to manage and no webhook process to keep alive.
The same guest list can be enforced three ways at once, and that's what makes a rollout survivable. enforce turns a violating pod away at the door. audit lets it in but writes the violation to the API server's audit log for later. warn lets it in too and prints a warning straight back to whoever ran kubectl. Each mode is a separate label, so you can set warn and audit to restricted while enforce still sits at the gentler baseline. You can also pin each mode to a specific Kubernetes version with an enforce-version label. Leave that pin off and the namespace always tracks the newest definition of the standard, which is riskier than it sounds: the standards gain checks as Kubernetes grows, so an upgrade can start refusing pods that were perfectly legal the week before. Pinning is an availability control, not a stricter setting. It buys you the right to move to the new definition on a day you picked, watch what breaks, and fix it, rather than finding out during someone else's control plane upgrade.
Roll it out in the right order
Flipping a live namespace straight to enforce=restricted is how you find out at 2am which of your workloads were never compliant. Do it in stages. Set warn and audit to restricted first, then watch: every non-compliant deploy now prints a warning to the person running it and drops an annotation in the audit log, and nothing actually breaks. Fix the pods that trip, and only once the warnings go quiet do you turn enforce on. The label change itself hands you one last look: when you set or raise enforce, the API server grades the pods already in the namespace against the new level and prints every violation straight back into your kubectl output as a warning. Read those before you walk away. Nothing is evicted, so the flip does not knock over live traffic, but don't hear that as nothing will break. A violating pod that is already running survives only until something reschedules it: a node drain, a reboot, an out-of-memory kill, a scale-up. The replacement is refused at admission, and the Deployment quietly shrinks instead of failing loudly. Treat every name in those warnings as a pod you have already lost, just not yet. As for the plugin itself, there's nothing to switch on: PodSecurity ships enabled by default since v1.25. If you're auditing against the CIS (Center for Internet Security) Kubernetes Benchmark, the relevant control is a manual one, 5.2.1, which just reminds you that every namespace running user workloads needs some policy mechanism in place. kube-bench flags it for a human to confirm rather than proving it for you.
$ kubectl label ns payments \pod-security.kubernetes.io/enforce=restricted \pod-security.kubernetes.io/enforce-version=v1.31 \pod-security.kubernetes.io/warn=restricted \pod-security.kubernetes.io/audit=restrictedWarning: existing pods in namespace "payments" violate the new PodSecurity enforce level "restricted:v1.31"Warning: legacy-worker-6f8d9: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfilenamespace/payments labeled
# the enforce label is actually set$ kubectl get ns payments -L pod-security.kubernetes.io/enforceNAME STATUS AGE ENFORCEpayments Active 40d restricted# CIS benchmark: the manual reminder to have a policy mechanism everywhere$ kube-bench run --targets policies | grep "5.2.1"[WARN] 5.2.1 Ensure that the cluster has at least one active policy control mechanism in place (Manual)# the real proof: a root, privileged pod bounces at admission$ kubectl run rogue --image=nginx -n payments \--overrides='{"spec":{"containers":[{"name":"rogue","image":"nginx","securityContext":{"privileged":true}}]}}'Error from server (Forbidden): pods "rogue" is forbidden:violates PodSecurity "restricted:v1.31": privileged (container "rogue"must not set securityContext.privileged=true), allowPrivilegeEscalation!= false, unrestricted capabilities, runAsNonRoot != true, seccompProfile(container "rogue" must set securityContext.seccompProfile.type to"RuntimeDefault" or "Localhost")
What restricted actually asks for
A pod that clears restricted is the whole securityContext lesson turned into a hard requirement: runAsNonRoot true, allowPrivilegeEscalation false, every capability dropped, a seccomp profile set, no host namespaces, and safe volumes only. Learn to write one from memory, because you'll do it constantly. When PSA rejects a pod, the fix is almost always a single missing field, and the rejection message names that field for you. Read it top to bottom and add back what it asks for.
spec:securityContext:runAsNonRoot: trueseccompProfile: { type: RuntimeDefault }containers:- name: appimage: registry.internal/app:1.4.2securityContext:allowPrivilegeEscalation: falsecapabilities: { drop: ["ALL"] }
$ kubectl apply -f restricted-pod.yaml -n paymentspod/app created$ kubectl get pod app -n paymentsNAME READY STATUS RESTARTS AGEapp 1/1 Running 0 9s
privileged baseline restricted are standards, not suggestions. Restricted is where most microservices should live once you drop capabilities and run as non-root.
Exemptions are not something PSA hands you for free. Nothing is exempt by default, not even kube-system. You get one by writing an AdmissionConfiguration file that lists the exempt usernames, runtime class names, or namespaces, then pointing the API server at it with --admission-control-config-file. That is the one part of PSA you can't do with a label, and on a managed control plane such as EKS, GKE, or AKS you usually can't do it at all, because the API server flags aren't yours to set. Wherever you can, keep the list short and reviewed; every exemption is a hole in the bouncer line.
Label strategies differ: some teams enforce restricted on all app namespaces and keep a single privileged sandbox namespace for CSI and service-mesh init work. Whatever you choose, make the labels part of namespace creation automation so humans cannot forget. Write the verification command next to the control in the same pull request, and keep the sample output so the next on-call person can tell pass from fail without guessing.
When three profiles are not enough
PSA knows those three standards and nothing else. It can't insist that images come only from your registry, or that every pod carries a team label, or that nobody ships a :latest tag. Rules specific to your organization need a policy engine sitting in the admission chain right next to PSA. Think of it as a second bouncer who also enforces the house rules your company wrote, not just the standard ones. Two are common. OPA/Gatekeeper (Open Policy Agent, whose constraints are written in a language called Rego and packaged as ConstraintTemplates) and Kyverno (whose policies are plain YAML). Both run as admission webhooks, both can validate an incoming spec, and both can mutate one on the way in. Use PSA for the standard baseline and a policy engine for everything bespoke.
apiVersion: kyverno.io/v1kind: ClusterPolicymetadata: { name: disallow-latest-tag }spec:rules:- name: require-image-tagmatch: { any: [{ resources: { kinds: [Pod] } }] }validate:failureAction: Enforcemessage: "an image tag is required"pattern:spec:containers:- image: "*:*"- name: validate-image-tagmatch: { any: [{ resources: { kinds: [Pod] } }] }validate:failureAction: Enforcemessage: "using a mutable tag such as latest is not allowed"pattern:spec:containers:- image: "!*:latest"
It takes two rules, not one. The pattern !*:latest bans only that literal string, so an image written as registry.internal/app with no tag at all sails straight through and then resolves to latest anyway when the kubelet pulls it. The first rule insists there is a tag; the second insists the tag isn't latest. Drop either one and the policy has a hole the exact shape of the thing you were trying to stop.
$ kubectl apply -f kyverno-disallow-latest.yamlclusterpolicy.kyverno.io/disallow-latest-tag created$ kubectl get clusterpolicy disallow-latest-tagNAME ADMISSION BACKGROUND READY AGEdisallow-latest-tag true true True 12s# the obvious case: an explicit :latest tag$ kubectl run web --image=nginx:latest -n paymentsError from server: admission webhook "validate.kyverno.svc-fail" deniedthe request: resource Pod/payments/web was blocked due to the followingpolicies: disallow-latest-tag: validate-image-tag: 'validation error:using a mutable tag such as latest is not allowed. rulevalidate-image-tag failed at path /spec/containers/0/image/'# the case the second rule alone would have missed: no tag at all$ kubectl run web --image=registry.internal/app -n paymentsError from server: admission webhook "validate.kyverno.svc-fail" deniedthe request: resource Pod/payments/web was blocked due to the followingpolicies: disallow-latest-tag: require-image-tag: 'validation error:an image tag is required. rule require-image-tag failed at path/spec/containers/0/image/'
Because PSA keys off a single namespace label, a namespace has exactly one enforced level, and it applies to every pod inside it. So when one workload genuinely needs more room, a node agent or a storage driver that has to run privileged, give it its own namespace with a looser label, then wrap tight RBAC (Role-Based Access Control) around who's allowed to deploy there. Don't carve an exception into your restricted namespace. An exception is a propped-open fire door: handy for the one person who needs it, and a way in for every pod that shouldn't be there.
Try this
Label a namespace restricted, try a privileged pod, and confirm it is rejected. Then ship a compliant pod and watch it schedule.
$ kubectl create ns pss-labnamespace/pss-lab created$ kubectl label ns pss-lab pod-security.kubernetes.io/enforce=restricted \pod-security.kubernetes.io/enforce-version=latestnamespace/pss-lab labeled$ kubectl -n pss-lab run bad --image=busybox:1.36 --restart=Never --privileged=trueError from server (Forbidden): pods "bad" is forbidden: violates PodSecurity "restricted:latest": privileged ...$ kubectl -n pss-lab apply -f locked-pod.yamlpod/locked created$ kubectl -n pss-lab get pod lockedNAME READY STATUS RESTARTS AGElocked 1/1 Running 0 8s
Takeaway
Pod Security Admission is the bouncer. Roll warn → audit → enforce, and make restricted the default for app namespaces.