How to Enforce Pod Security Standards with Kyverno 1.19 (2026)
This guide is for platform and security engineers who want every pod in a Kubernetes cluster checked against the Pod Security Standards, with a safe rollout path and a clear story for the workloads that genuinely need an exception. At the end you will have Pod Security Admission labels on every namespace, Kyverno enforcing the baseline and restricted profiles with CEL policies, a mutate policy that labels new namespaces, a generate policy that gives each namespace a default-deny NetworkPolicy, exceptions as reviewable objects, and a list of what to check when the Kyverno webhook starts failing requests. Versions: Kyverno v1.19.1 (Helm charts kyverno and kyverno-policies 3.9.1) on Kubernetes 1.35, the newest minor in Kyverno 1.19's supported range of 1.33 to 1.35. Every manifest below was checked against the v1.19.1 CRDs, chart source and conformance tests. You need kubectl, Helm 3, and cluster-admin on a non-production cluster to start.
If you are still deciding between Kyverno, OPA Gatekeeper and the built-in ValidatingAdmissionPolicy, read the policy engine comparison first. This guide assumes you picked Kyverno, and it uses only the new policies.kyverno.io types: Kyverno 1.19 deprecated ClusterPolicy and plans to remove it in 1.20, estimated November 2026, so writing new ClusterPolicies now is writing migration work for next month.
How Pod Security Admission and Kyverno fit together
Why both? Pod Security Admission (PSA) is built into the API server, has been stable since 1.25, and keeps working even if Kyverno is down. But it has no exceptions per workload (only cluster-wide exemptions by username, runtime class or namespace, configured on the API server), it enforces on pods rather than Deployments, and you cannot add rules. Kyverno fills those gaps. Running both is not duplication in the bad sense: PSA is the floor that does not depend on a webhook, Kyverno is the policy system people interact with.
Step 1: Label namespaces for Pod Security Admission
Start with a server-side dry run. It evaluates existing pods against the new level and prints warnings without changing anything:
# Kubernetes 1.35. Nothing is modified; the API server returns warnings for pods that would violate.
kubectl label --dry-run=server --overwrite ns --all \
pod-security.kubernetes.io/enforce=baseline
Then turn on warn and audit everywhere. Warn shows users a message at kubectl apply time; audit adds an annotation to the API audit log. Neither blocks anything:
kubectl label --overwrite ns --all \
pod-security.kubernetes.io/warn=restricted \
pod-security.kubernetes.io/audit=restricted
For namespaces you control, enforce now and pin the version so a cluster upgrade cannot silently tighten the profile:
kubectl create namespace demo
kubectl label --overwrite ns demo \
pod-security.kubernetes.io/enforce=baseline \
pod-security.kubernetes.io/enforce-version=v1.35
# List namespaces that still have no enforce level:
kubectl get namespaces --selector='!pod-security.kubernetes.io/enforce'
Leave kube-system and your CNI, CSI and node-agent namespaces at privileged for enforce. Their pods need host access by design. PSA can only say yes or no per namespace, so Step 8 uses Kyverno to narrow that yes down to the specific workloads that need it.
Step 2: Install Kyverno with Helm in high-availability mode
Kyverno's resource webhooks fail closed by default, so a single replica means any Kyverno restart blocks pod creation in matched namespaces. Use three admission controller replicas, as the installation docs recommend for production. The chart creates a PodDisruptionBudget automatically when replicas is greater than one.
# kyverno-values.yaml for chart kyverno/kyverno 3.9.1 (app v1.19.1)
admissionController:
replicas: 3
backgroundController:
replicas: 2
cleanupController:
replicas: 2
reportsController:
replicas: 2
features:
policyExceptions:
enabled: true # off by default; needed for Step 8
namespace: kyverno-exceptions # only exceptions in this namespace are honoured
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno \
--namespace kyverno --create-namespace \
--version 3.9.1 \
--values kyverno-values.yaml
kubectl create namespace kyverno-exceptions
kubectl -n kyverno rollout status deploy/kyverno-admission-controller
kubectl -n kyverno get pods
You should see three kyverno-admission-controller pods and two each of the background, cleanup and reports controllers, all Running. By default the chart excludes kube-system and the kyverno namespace from its webhooks through a namespaceSelector, which is what you want.
Step 3: Install the Pod Security Standards policies in Audit mode
The kyverno-policies chart packages the Pod Security Standards as Kyverno policies. In 3.9.1 its default policyType is ValidatingPolicy, so it installs the CEL types, not ClusterPolicies. Start with restricted (which includes the baseline checks) in Audit:
helm install kyverno-policies kyverno/kyverno-policies \
--namespace kyverno \
--version 3.9.1 \
--set podSecurityStandard=restricted \
--set validationFailureAction=Audit
kubectl get validatingpolicies
You should see policies such as disallow-privileged-containers, disallow-host-path, disallow-capabilities-strict, require-run-as-nonroot and restrict-seccomp-strict. Each matches pods only, and Kyverno automatically generates matching rules for Deployments, StatefulSets, DaemonSets, Jobs and CronJobs, so a bad Deployment is caught at kubectl apply rather than later at the ReplicaSet. One trap from the source: autogen is skipped when a policy has excludeResourceRules, so if you use the chart's vpolExclude values, check the controller rules still exist.
Test it with a deliberately bad pod in a scratch namespace that has no PSA enforce label, so you see Kyverno on its own:
kubectl create namespace scratch
cat <<'YAML' > bad-pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: bad
spec:
containers:
- name: app
image: nginx:1.29
securityContext:
privileged: true
YAML
kubectl -n scratch apply -f bad-pod.yaml --dry-run=server
In Audit mode the pod is admitted and the violation lands in a report. Apply the same file in demo and PSA baseline rejects it outright, which is the floor doing its job.
Step 4: Read policy reports before you enforce
Audit mode is only useful if someone reads the results. Kyverno writes one PolicyReport per resource, named after the resource's UID, with pass, fail, warn, error and skip counts:
# Namespaced reports (pods, deployments, ...). Short name: polr
kubectl get policyreports -A
# Cluster-scoped resources (namespaces, ...). Short name: cpolr
kubectl get clusterpolicyreports
# Just the failures, one line per violation:
kubectl get polr -A -o json | jq -r '
.items[] | .scope as $s | .results[]? | select(.result=="fail")
| "\($s.namespace)/\($s.kind)/\($s.name)\t\(.policy)\t\(.message)"'
Expect a lot of failures from restrict-seccomp-strict and require-run-as-nonroot on older Helm charts; those are the usual long tail. Fix the workloads, or record an exception, until the fail count for the namespaces you care about reaches zero. That number is the gate for Step 9.
Step 5: Require a Pod Security level on every namespace (validate)
PSA only protects namespaces that carry a label. This custom ValidatingPolicy rejects namespaces without an enforce label, and rejects privileged outside an allowlist:
# require-psa-labels.yaml (Kyverno 1.17+, policies.kyverno.io/v1)
apiVersion: policies.kyverno.io/v1
kind: ValidatingPolicy
metadata:
name: require-psa-labels
spec:
validationActions: [Audit] # switch to [Deny] in Step 9
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: [v1]
operations: [CREATE, UPDATE]
resources: [namespaces]
variables:
- name: level
expression: "object.metadata.?labels[?'pod-security.kubernetes.io/enforce'].orValue('')"
validations:
- expression: "variables.level in ['baseline', 'restricted', 'privileged']"
message: "namespaces must set pod-security.kubernetes.io/enforce to baseline, restricted or privileged"
- expression: >-
variables.level != 'privileged' ||
object.metadata.name in ['kube-system', 'kube-node-lease', 'kyverno', 'monitoring']
message: "only platform namespaces may use the privileged Pod Security level"
kubectl apply -f require-psa-labels.yaml
kubectl get validatingpolicy require-psa-labels
Because the mutate policy in the next step runs first, new namespaces will already carry labels by the time this check runs. Its real job is stopping someone from removing or downgrading them later.
Step 6: Label new namespaces automatically (mutate)
A MutatingPolicy that adds default PSA labels to any new namespace that does not set its own enforce level. Mutating webhooks run before validating ones, so this satisfies Step 5 for teams that do not care, while teams that set their own label keep it:
# default-psa-labels.yaml
apiVersion: policies.kyverno.io/v1
kind: MutatingPolicy
metadata:
name: default-psa-labels
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: [v1]
operations: [CREATE]
resources: [namespaces]
matchConditions:
- name: no-enforce-label-yet
expression: "!('pod-security.kubernetes.io/enforce' in object.metadata.?labels.orValue({}))"
mutations:
- patchType: ApplyConfiguration
applyConfiguration:
expression: >
Object{
metadata: Object.metadata{
labels: {
"pod-security.kubernetes.io/enforce": "baseline",
"pod-security.kubernetes.io/enforce-version": "v1.35",
"pod-security.kubernetes.io/warn": "restricted",
"pod-security.kubernetes.io/audit": "restricted"
}
}
}
kubectl apply -f default-psa-labels.yaml
kubectl create namespace team-a
kubectl get ns team-a --show-labels
The output should list the four pod-security.kubernetes.io labels next to kubernetes.io/metadata.name=team-a. The apply configuration merges keys into the existing label map rather than replacing it.
Step 7: Give every namespace a default-deny NetworkPolicy (generate)
Pod Security Standards control what a pod can do on its node; they say nothing about the network. A GeneratingPolicy can create a default-deny ingress NetworkPolicy in every new namespace, and with synchronize it puts the policy back if someone deletes it. The shape follows Kyverno's v1.19.1 conformance tests:
# default-deny-ingress.yaml
apiVersion: policies.kyverno.io/v1
kind: GeneratingPolicy
metadata:
name: default-deny-ingress
spec:
evaluation:
synchronize:
enabled: true
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: [v1]
operations: [CREATE]
resources: [namespaces]
variables:
- name: nsName
expression: "object.metadata.name"
- name: netpol
expression: >-
[
{
"kind": dyn("NetworkPolicy"),
"apiVersion": dyn("networking.k8s.io/v1"),
"metadata": dyn({"name": "default-deny-ingress"}),
"spec": dyn({
"podSelector": dyn({}),
"policyTypes": dyn(["Ingress"])
})
}
]
generate:
- expression: generator.Apply(variables.nsName, variables.netpol)
kubectl apply -f default-deny-ingress.yaml
kubectl create namespace team-b
kubectl -n team-b get networkpolicy default-deny-ingress
Kyverno's background controller does the creating; the 3.9.1 chart already grants it create, update, patch and delete on NetworkPolicies, but any other kind you generate needs extra RBAC. Allow rules on top of the default deny are covered in the network policies guide.
Step 8: Grant exceptions with PolicyException
Node exporters, CNI agents and log shippers need host paths and host networking. Do not loosen the policy for them; write a scoped exception in the exceptions namespace you configured in Step 2, so every exception is a reviewable object in git:
# exception-node-exporter.yaml
apiVersion: policies.kyverno.io/v1
kind: PolicyException
metadata:
name: node-exporter-host-access
namespace: kyverno-exceptions
annotations:
owner: "platform-observability"
ticket: "SEC-1234"
spec:
policyRefs:
- name: disallow-host-path
kind: ValidatingPolicy
- name: disallow-host-namespaces
kind: ValidatingPolicy
- name: disallow-host-ports
kind: ValidatingPolicy
matchConditions:
- name: only-node-exporter-in-monitoring
expression: >-
object.metadata.namespace == 'monitoring' &&
object.metadata.?labels[?'app.kubernetes.io/name'].orValue('') == 'node-exporter'
kubectl apply -f exception-node-exporter.yaml
kubectl -n kyverno-exceptions get policyexceptions
The exception only bypasses Kyverno. If monitoring enforces baseline in PSA, node-exporter is still rejected by PSA, so that namespace needs enforce=privileged (which the Step 5 allowlist permits) with Kyverno doing the fine-grained work. That pairing, coarse PSA level plus precise Kyverno exception, is the pattern I would use for every system namespace.
Step 9: Switch from Audit to Enforce
Once the reports from Step 4 are clean for a namespace set, flip the chart and your custom policy to Deny:
helm upgrade kyverno-policies kyverno/kyverno-policies \
--namespace kyverno --version 3.9.1 \
--reuse-values \
--set validationFailureAction=Enforce
kubectl patch validatingpolicy require-psa-labels --type merge \
-p '{"spec":{"validationActions":["Deny"]}}'
The chart maps Enforce to validationActions: [Deny]. To roll out one policy at a time, use validationFailureActionByPolicy in a values file instead. Now test again:
kubectl -n scratch apply -f bad-pod.yaml
A rejection looks like this (the format comes from Kyverno's conformance tests):
Error from server: admission webhook "vpol.validate.kyverno.svc-fail" denied the request:
Policy disallow-privileged-containers failed: Privileged mode is disallowed. ...
Then raise PSA too: move application namespaces from enforce=baseline to enforce=restricted, dry-running each with kubectl label --dry-run=server first.
Troubleshooting
When the webhook itself fails, the API server cannot tell whether your pod is allowed, and with failurePolicy: Fail it rejects the request. The Kyverno troubleshooting docs list a probe to run from inside the cluster:
kubectl run nettest --rm -it --image=busybox:1.37 --restart=Never -- \
wget --no-check-certificate --spider --timeout=1 \
https://kyverno-svc.kyverno.svc:443/health/liveness
# "remote file exists" means the service is reachable; anything else is network or DNS.
Break glass if Kyverno is down and blocking the cluster (Kyverno recreates these when it recovers):
kubectl delete validatingwebhookconfiguration kyverno-resource-validating-webhook-cfg
kubectl delete mutatingwebhookconfiguration kyverno-resource-mutating-webhook-cfg
| Symptom | Likely cause | Fix |
|---|---|---|
failed calling webhook "vpol.validate.kyverno.svc-fail" ... connect: connection refused |
No ready admission controller pods | kubectl -n kyverno get pods; check events and OOMKills; run 3 replicas |
Post "https://kyverno-svc.kyverno.svc:443/validate?timeout=10s": context deadline exceeded |
Control plane cannot reach pods on port 9443 (GKE private firewall, EKS security group, old VPC CNI) | Allow control plane to nodes on TCP 9443; upgrade VPC CNI; on custom CNIs set admissionController.hostNetwork=true |
| Deployment accepted but no pods appear; ReplicaSet events show denial | Policy autogen skipped (e.g. excludeResourceRules), or PSA enforce rejects the pods |
kubectl describe rs; check kubectl get validatingpolicy NAME -o yaml status for autogen rules |
| Pod rejected by PSA despite a Kyverno PolicyException | Exceptions do not apply to Pod Security Admission | Set the namespace PSA level to the needed profile and keep Kyverno for detail |
| PolicyException has no effect | Feature disabled or exception outside the configured namespace | Set features.policyExceptions.enabled=true and namespace; check policyRefs kind is ValidatingPolicy |
kubectl get polr returns nothing |
Reports controller down, or background evaluation disabled in the policy | Check kyverno-reports-controller logs; ensure spec.evaluation.background.enabled is not false |
| GeneratingPolicy creates nothing | Background controller lacks RBAC for the target kind (NetworkPolicies are covered by default) | Add the kind to backgroundController.rbac.coreClusterRole.extraResources; check kyverno-background-controller logs |
no matches for kind "ClusterPolicy" after an upgrade |
Running Kyverno 1.20, where legacy types are removed | Migrate to CEL types; see the migration guide linked from the 1.19 release post |
| Policies rejected with CEL compile errors | Field access on missing keys | Use has(), optional fields object.?field and orValue() |
FAQ
Do I still need Pod Security Admission if I run Kyverno?
Yes, I would keep both. PSA runs inside the API server and keeps protecting namespaces when the Kyverno webhook is unavailable or deliberately removed in an emergency. Kyverno adds per-workload exceptions, Deployment-level feedback, mutation, generation and reports.
What is the difference between Audit and Enforce in Kyverno?
In the CEL policy types the field is validationActions. Audit records failures in PolicyReports and admits the resource, Deny rejects it at admission. The kyverno-policies chart maps its validationFailureAction: Enforce value to [Deny] and anything else to [Audit].
Should I use ClusterPolicy or ValidatingPolicy in 2026?
ValidatingPolicy. Kyverno 1.19 is the last release with full support for ClusterPolicy, and removal is planned for 1.20, estimated November 2026. The 3.9.1 policies chart already installs the CEL types by default.
Which Pod Security Standard should I enforce, baseline or restricted?
Enforce baseline everywhere first, because it blocks known privilege escalations and most off-the-shelf charts already comply. Run restricted in warn and audit mode, fix workloads using the reports, and then enforce it for application namespaces one at a time.
What I check first in production, in order: that the admission controller has three ready replicas and a PodDisruptionBudget, that kubectl get namespaces --selector='!pod-security.kubernetes.io/enforce' returns only namespaces I expect, and that the fail count across PolicyReports is trending down rather than being ignored. Then I make sure every PolicyException has an owner annotation and lives in git. The official references are the Pod Security Admission docs, the namespace labels task, the Kyverno ValidatingPolicy docs, and Kyverno exceptions.
Related: Kyverno vs Gatekeeper vs ValidatingAdmissionPolicy in 2026, Policy as Code for AI Agents in 2026: Cedar at the Gateway, Rego in the Hook, and the 11.2% the Model Still Lets Through and Container vs microVM vs gVisor: Choosing Agent Isolation.
I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.
Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.
Related
How to Set Up cert-manager with Let's Encrypt and Fix Errors (2026)
Install cert-manager v1.21 with Helm, create Let's Encrypt ClusterIssuers for HTTP-01 (Gateway API or Ingress) and DNS-01, then debug a Certificate stuck not ready by walking CertificateRequest, Order and Challenge, with rate limits, ARI and alerts.
14 minOct 20, 2026How to Use External Secrets Operator with Vault on Kubernetes (2026)
Step-by-step External Secrets Operator v2.13 setup with HashiCorp Vault, OpenBao or AWS Secrets Manager: Helm install, Kubernetes auth, ClusterSecretStore, ExternalSecret, rotation and reloads, ESO's 2025 release pause, alternatives, and fixes for SecretSyncedError.
14 minOct 20, 2026How to Detect Runtime Threats with Falco on Kubernetes (2026)
Step-by-step Falco 0.45 setup on Kubernetes: Helm chart 9.2.0 with the modern eBPF driver, default and custom rules, Falcosidekick alerts to Slack, tuning noise, Falco Talon response actions, drop metrics, and a troubleshooting table for driver errors.
15 min