Pod Security Standards: enforce restricted without breakage

Roll out the restricted profile namespace by namespace, find violators with audit mode first, and carve exceptions that expire.

Apr 28, 2026·Updated ·5 min readAdvanced·By SecOpsLog · command-tested
bash — observed: what enforce looks like from the developer’s side (Kubernetes 1.35.0, namespace enforcing restricted:v1.35)observed
kubectl apply -f deploy.yaml; echo exit=$?
deployment.apps/api unchanged
exit=0
the Deployment is accepted: enforce applies to Pods, not to the template
kubectl get rs -n payments -l app=api -o jsonpath="{range .items[*]}{.metadata.name} replicas={.status.replicas} {.status.conditions[0].type}: {.status.conditions[0].message}{'\n'}{end}"
api-7f987c4746 replicas=0 ReplicaFailure: pods "api-7f987c4746-mt2rj" is forbidden: violates PodSecurity "restricted:v1.35": allowPrivilegeEscalation != false (container "api" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "api" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "api" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "api" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
zero replicas come up, and the reason is on the ReplicaSet as a ReplicaFailure condition, not on the apply. The message names every field to change

That failure mode is the whole argument for a phased rollout. Pod Security Admission enforces the three Pod Security Standards with one namespace label, and enforce rejects Pods, not the Deployment that creates them: kubectl apply succeeds, the ReplicaSet fails to create replicas, and the person who finds out is whoever notices the service has no endpoints. The warn and audit modes exist to move that discovery earlier, to the developer's terminal and to the audit log, while nothing is blocked.

Modes and levels

Three modes per namespace, each set to one of three levels. The rollout below moves a namespace from warn+audit at restricted to enforce at restricted without a step where workloads are silently blocked.

Namespace PSA labels Pod Security Admission built into apiserver Pod create admit or reject 1 Modes warn · audit · enforce start warn → then enforce exemptions for system NS 2 Levels privileged → baseline → restricted restricted = least privilege no root, no hostPath… 3 Roll out label per namespace fix dry-run before enforce fix workloads that break Modes × levels are independent. Enforce restricted only after warn is clean. namespace labels → PSA → admit/reject
Modes — warn/audit/enforceLevels — baseline/restrictedRollout — per NS

Three profiles, and which namespaces get which

Pod Security Standards

LevelBlocksTypical namespaces
privilegednothingCNI, storage drivers, node agents that genuinely need host access
baselinehost namespaces, privileged containers, most hostPath volumes, added capabilities beyond a default set, host portsingress controllers and monitoring agents that need something restricted forbids, with a written reason
restrictedeverything baseline blocks, plus: must set runAsNonRoot, drop ALL capabilities (NET_BIND_SERVICE may be added back), allowPrivilegeEscalation false, a seccomp profile, only the safe volume typesapplication namespaces
Go deeper in a courseKubernetes administrationAdmission control, RBAC and the rest of the control plane this sits in.View course

Step one: warn and audit at the target level

Label the namespace with warn and audit at restricted and change nothing else. warn returns a warning on every kubectl apply that would fail under enforcement, including applies of Deployments and other workload objects, because warn and audit evaluate the Pod template inside them. audit records the same violations as annotations on the API server's audit events, which is the complete list, including the workloads nobody has redeployed since the label went on. Both modes admit the object; in the run the non-compliant Deployment was created with the warning below and its replica was available a second later.

bash — observed: warn and audit at restricted, then the non-compliant Deploymentobserved
kubectl label ns payments pod-security.kubernetes.io/warn=restricted pod-security.kubernetes.io/warn-version=v1.35 pod-security.kubernetes.io/audit=restricted pod-security.kubernetes.io/audit-version=v1.35
namespace/payments labeled
kubectl apply -f deploy.yaml
Warning: would violate PodSecurity "restricted:v1.35": allowPrivilegeEscalation != false (container "api" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "api" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "api" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "api" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
deployment.apps/api created
kubectl apply -f deploy-fixed.yaml # runAsNonRoot, RuntimeDefault seccomp, no privilege escalation, drop ALL
deployment.apps/api configured
no warning the second time: the four fields are the whole difference between the two files
namespace.yaml (observe only)
apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: v1.37
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: v1.37

Harvesting the list needs the audit log to be on; the violation text is in the pod-security.kubernetes.io/audit-violations annotation of each event. One sprint of normal deploys is usually enough for every workload in the namespace to have been evaluated at least once.

bash — representative: the violator list from the audit log (the fixture cluster has no audit backend)
jq -r 'select(.annotations["pod-security.kubernetes.io/audit-violations"]) | .objectRef.namespace + "/" + .objectRef.resource + "/" + .objectRef.name + ": " + .annotations["pod-security.kubernetes.io/audit-violations"]' audit.log | sort -u
payments/deployments/api: would violate PodSecurity "restricted:v1.37": allowPrivilegeEscalation != false (container "api"), runAsNonRoot != true (pod or container "api"), seccompProfile (pod or container "api") must be set
payments/cronjobs/reconcile: would violate PodSecurity "restricted:v1.37": unrestricted capabilities (container "job" must set securityContext.capabilities.drop=["ALL"])
each line is one workload and the exact fields to change

Step two: fix the securityContext, not the policy

Every violation maps to a field. The pod-level securityContext takes runAsNonRoot: true and seccompProfile: { type: RuntimeDefault }; each container takes allowPrivilegeEscalation: false and capabilities: { drop: ["ALL"] }. An image that runs as root needs a numeric runAsUser in the manifest and a filesystem that user can use, which is an image change rather than a YAML change. For Helm-managed workloads the fix goes into the chart values or an upstream issue, otherwise the next helm upgrade reverts it.

deploy.yaml (the fields restricted asks for)
spec:
template:
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: api
securityContext:
allowPrivilegeEscalation: false
capabilities:
drop: ["ALL"]

Step three: enforce, pinned to a version

When the audit log has been quiet for the namespace, add the enforce label. Pin enforce-version to a minor version rather than latest: the standards can tighten with a Kubernetes release, and latest means a cluster upgrade can start rejecting Pods that passed the day before. Keep warn and audit on latest so the upcoming tightening shows up as warnings first. Adding or changing an enforce label triggers a dry-run evaluation of the namespace's existing Pods, and the resulting warnings are your last chance to catch something before a Deployment rollout tries to replace those Pods. Two things about the version label from the run, on a 1.35.0 server: a label naming a version that server did not have yet (v1.37) was accepted without any warning and echoed in the violation text as restricted:v1.37, so in that run a wrong minor number was not caught by the API server; and 1.35 without the v was rejected outright with must be "latest" or "v1.x". The Pod Security Admission documentation says how a version is resolved; what the run shows is that the server will not catch the mistake for you, so pin deliberately to the version you actually run and review the label when the cluster is upgraded.

namespace.yaml (enforced)
labels:
pod-security.kubernetes.io/enforce: restricted
pod-security.kubernetes.io/enforce-version: v1.37
pod-security.kubernetes.io/warn: restricted
pod-security.kubernetes.io/warn-version: latest
pod-security.kubernetes.io/audit: restricted
pod-security.kubernetes.io/audit-version: latest
bash — observed: enforce added while non-compliant Pods run, the refused Pod, and the two ways backobserved
kubectl label ns payments pod-security.kubernetes.io/enforce=restricted pod-security.kubernetes.io/enforce-version=v1.35
Warning: existing pods in namespace "payments" violate the new PodSecurity enforce level "restricted:v1.35"
Warning: api-7f987c4746-d8h22 (and 1 other pod): allowPrivilegeEscalation != false, unrestricted capabilities, runAsNonRoot != true, seccompProfile
namespace/payments labeled
the dry run names the Pods that would not be admitted again; they keep running (Running in kubectl get pod) until something replaces them
kubectl apply -f privileged-pod.yaml; echo exit=$?
Error from server (Forbidden): error when creating "privileged-pod.yaml": pods "priv" is forbidden: violates PodSecurity "restricted:v1.35": privileged (container "priv" must not set securityContext.privileged=true), allowPrivilegeEscalation != false (container "priv" must set securityContext.allowPrivilegeEscalation=false), unrestricted capabilities (container "priv" must set securityContext.capabilities.drop=["ALL"]), runAsNonRoot != true (pod or container "priv" must set securityContext.runAsNonRoot=true), seccompProfile (pod or container "priv" must set securityContext.seccompProfile.type to "RuntimeDefault" or "Localhost")
exit=1
apply_fixed_wait # fixture helper: kubectl apply -f deploy-fixed.yaml, then poll .status.availableReplicas
available replicas: 1 after 2s
the fix: the ReplicaSet for the compliant template creates its Pod at once
unlabel_apply_bad_wait # fixture helper: remove the two enforce labels, apply deploy.yaml, count its warning lines, poll availableReplicas
1
available replicas: 1 after 0s
the other way out, for a namespace that must ship now: remove the enforce labels and the non-compliant template is admitted again, still with the warning. It is a rollback of the control, not a fix

When enforce bites: recovery in order of preference

SymptomDiagnosisReversal and verification
a Deployment has zero available replicas after a rollout, kubectl apply said nothingkubectl get rs -n <ns> -o jsonpath shows a ReplicaFailure condition with violates PodSecurity and the fieldsapply the securityContext fields the message names; verify availableReplicas (executed: 1 within two seconds of the fixed apply)
the fix cannot ship today and the service is downthe namespace was moved to enforce with unfixed workloadsremove pod-security.kubernetes.io/enforce and enforce-version from the namespace; the ReplicaSet recreates the Pod immediately, warn and audit keep reporting (executed); put enforce back with the fix in the same change
Pods keep running but the label change printed existing pods … violatethe dry run at label time found workloads not yet redeployednothing to revert yet: fix those workloads before their next rollout, node drain or crash replaces them (the dry-run warning was observed; the later replacement failure is the first row)
a namespace that must run privileged workloads is now blockedthe wrong level for that namespacelabel it baseline or privileged with a written reason, or exempt the controller in the AdmissionConfiguration (documentation-backed; not executed)
What was run for this article
Kubernetes 1.35.0 on a single-node kind v0.31.0 cluster created and deleted by the fixture (kindest/node:v1.35.0 by digest, kubectl v1.35.0, Docker Engine 28.5.2, linux/arm64), Pod Security Admission as built into that API server. The terminal blocks marked observed are copied from that run (Pod and ReplicaSet names are from the recorded run); fourteen exit codes are asserted: the warn/audit labels, the warned apply and its running replica, the fixed template rolling out, the enforce label’s dry-run warning, the refused privileged Pod, the ReplicaFailure condition, both recoveries, and the two version-label cases. The audit-log harvest needs an audit backend on the API server and was not executed; Gatekeeper and Kyverno are not part of this fixture.
Exemptions live in the admission configuration
Workloads that can never meet baseline (a CNI DaemonSet, a storage driver) belong in a namespace labelled privileged with a written reason and a review date, or in an exemption in the PodSecurity AdmissionConfiguration by username, RuntimeClass or namespace. Exemptions skip all three modes for the matching requests, so they are for a controller identity or a system namespace, never for an application team that finds restricted inconvenient.
What PSA decides, and what it cannot express
PSA
Pod-level security posture, per namespace
Three fixed profiles, versioned
Built into the API server, no webhook
Warn and audit before enforce
Needs a policy engine
"images only from our registry"
"every Deployment carries an owner label"
Mutation (adding defaults)
Anything not in the standards

The right-hand column is where Gatekeeper or Kyverno come in, after PSA has set the floor. Their rollout discipline is the same one described above, under a different name: dry run, fix, then deny.

Related posts

Quick reference