CoursesKubernetes security & hardeningPod Security Standards & admission

Pod Security Standards & admission

Baseline, restricted, and OPA/Gatekeeper.

Advanced14 min · lesson 14 of 24

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.

One violating pod, three possible reactions
Pod violates restricted
runs as root, or sets no seccomp profile
enforce
Rejected at admission
pod is never created; kubectl returns Forbidden
audit
Allowed, recorded
a violation annotation is written to the audit log
warn
Allowed, flagged
a warning prints to the client and the deploy proceeds
The three modes are independent labels. Turn on warn and audit first to see who fails, then flip enforce once the noise is gone.

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.

terminal
$ 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=restricted
Warning: existing pods in namespace "payments" violate the new PodSecurity enforce level "restricted:v1.31"
Warning: legacy-worker-6f8d9: allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/payments labeled
terminal
# the enforce label is actually set
$ kubectl get ns payments -L pod-security.kubernetes.io/enforce
NAME STATUS AGE ENFORCE
payments 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")
The Deployment applies clean, the pods never come
When a pod template inside a Deployment, Job, or CronJob violates the enforced level, the controller object itself is admitted just fine. PSA only rejects the pods that controller then tries to spawn. So kubectl apply on the Deployment reports success, and yet nothing runs. The rejection shows up in the ReplicaSet's events, which is exactly where nobody's looking mid-rollout. After you enable enforce, check kubectl get events, not just the exit code of your apply.

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.

restricted-pod.yaml
spec:
securityContext:
runAsNonRoot: true
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: registry.internal/app:1.4.2
securityContext:
allowPrivilegeEscalation: false
capabilities: { drop: ["ALL"] }
terminal
$ kubectl apply -f restricted-pod.yaml -n payments
pod/app created
$ kubectl get pod app -n payments
NAME READY STATUS RESTARTS AGE
app 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.

kyverno-disallow-latest.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: disallow-latest-tag }
spec:
rules:
- name: require-image-tag
match: { any: [{ resources: { kinds: [Pod] } }] }
validate:
failureAction: Enforce
message: "an image tag is required"
pattern:
spec:
containers:
- image: "*:*"
- name: validate-image-tag
match: { any: [{ resources: { kinds: [Pod] } }] }
validate:
failureAction: Enforce
message: "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.

terminal
$ kubectl apply -f kyverno-disallow-latest.yaml
clusterpolicy.kyverno.io/disallow-latest-tag created
$ kubectl get clusterpolicy disallow-latest-tag
NAME ADMISSION BACKGROUND READY AGE
disallow-latest-tag true true True 12s
# the obvious case: an explicit :latest tag
$ kubectl run web --image=nginx:latest -n payments
Error from server: admission webhook "validate.kyverno.svc-fail" denied
the request: resource Pod/payments/web was blocked due to the following
policies: disallow-latest-tag: validate-image-tag: 'validation error:
using a mutable tag such as latest is not allowed. rule
validate-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 payments
Error from server: admission webhook "validate.kyverno.svc-fail" denied
the request: resource Pod/payments/web was blocked due to the following
policies: 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.

terminal
$ kubectl create ns pss-lab
namespace/pss-lab created
$ kubectl label ns pss-lab pod-security.kubernetes.io/enforce=restricted \
pod-security.kubernetes.io/enforce-version=latest
namespace/pss-lab labeled
$ kubectl -n pss-lab run bad --image=busybox:1.36 --restart=Never --privileged=true
Error from server (Forbidden): pods "bad" is forbidden: violates PodSecurity "restricted:latest": privileged ...
$ kubectl -n pss-lab apply -f locked-pod.yaml
pod/locked created
$ kubectl -n pss-lab get pod locked
NAME READY STATUS RESTARTS AGE
locked 1/1 Running 0 8s

Takeaway

Pod Security Admission is the bouncer. Roll warn → audit → enforce, and make restricted the default for app namespaces.

Quick check
01You flip a namespace to enforce=restricted while several non-compliant pods are already running there. What happens to them?
Incorrect — PSA validates pods at admission time. Setting the label makes it grade the existing pods and warn you about each violator, but nothing is evicted or recreated.
Correct — Enabling enforce gates future admissions, not live pods. The catch is that a reschedule counts as a future admission, so a violating pod that gets drained or restarted does not come back.
Incorrect — The label is applied regardless. PSA does not validate the change against running pods.
Incorrect — There is no pause behavior. Running pods are untouched and only new pod creation is gated.
02Your team wants to block any pod that uses a :latest image tag. Can Pod Security Admission (PSA) enforce that on its own?
Incorrect — restricted governs privilege, host access, and capabilities, not image tags.
Incorrect — the three PSS standards are fixed; you can't extend them with custom rules.
Correct — bespoke rules such as registry allow-lists or tag bans belong to a policy engine sitting alongside PSA in the admission chain.
Incorrect — an enforce-version pin freezes the standard's version; it adds no tag checking.
03You enable enforce=restricted on a namespace, then kubectl apply a Deployment whose pod template runs as root. The apply reports success, yet no pods ever appear. Where does the rejection show up?
Correct — the Deployment and ReplicaSet are admitted fine; PSA only rejects the pods the ReplicaSet then tries to spawn, and that lands in its events.
Incorrect — the Deployment object itself is admitted cleanly, so its status looks normal.
Incorrect — the apply genuinely succeeds because PSA gates pods, not the controller object.
Incorrect — enforce rejections surface in pod/ReplicaSet events; audit is a separate, non-blocking mode.

Related