Native ValidatingAdmissionPolicy vs Kyverno, Same Two Controls
The registry allow-list and the digest-pinning control were reimplemented as native CEL ValidatingAdmissionPolicies and run against the identical fixtures. Same verdict on every Pod — and CEL's messageExpression names the offending image.
Two of our admission controls are simple enough to be suspicious: an image registry allow-list, and a requirement that images be pinned by digest. Neither needs an API call, a context variable, or anything Kyverno uniquely provides. So the question is whether they need Kyverno at all, or whether ValidatingAdmissionPolicy — in-tree since Kubernetes 1.30, CEL-based, no webhook — does the same job.
The cluster (1.35.3) serves both objects as admissionregistration.k8s.io/v1:
validatingadmissionpolicies admissionregistration.k8s.io/v1 ValidatingAdmissionPolicy
validatingadmissionpolicybindings admissionregistration.k8s.io/v1 ValidatingAdmissionPolicyBindingConveniently, Kyverno CLI can evaluate native VAPs offline, so both implementations were tested by the same harness against the same fixtures.
The verdicts
| Control | Fixtures | VAP result |
| Registry allow-list | 4 FAIL Pods | pass 0, fail 4, exit 1 |
| Registry allow-list | 3 PASS Pods | pass 3, fail 0, exit 0 |
| Tag / digest | 4 FAIL Pods | pass 0, fail 4, exit 1 |
| Tag / digest | 2 PASS Pods | pass 2, fail 0, exit 0 |
Every Pod lands on the side Kyverno puts it on. No fixture changed sides, in either direction, for either control.
The rule accounting differs, though. Each VAP is Applying 1 policy rule(s) — one policy object, one CEL expression, one verdict. The Kyverno equivalents apply 3 rules for the registry control and 9 for the digest control, because Kyverno's autogen expands each rule across the workload controllers. Same outcome, an order of magnitude fewer moving parts to reason about.
Identical verdicts on every fixture; one CEL rule where Kyverno autogen produces three or nine.
What messageExpression buys you
The interesting difference is not the verdict, it is the sentence the developer reads. CEL's messageExpression is evaluated against the request, so it can interpolate the thing that actually broke:
these images are not from an approved registry: nginx:1.29.4
(allowed prefixes: registry.private.example.com/, registry.gitlab.example.com/, harbor.poc.local/)the mutable tag :latest is not allowed: harbor.poc.local/poc/tc01:latest
images must be pinned by digest (repo:tag@sha256:...): harbor.poc.local/poc/tc01:v1Kyverno's static message strings cannot do that. On the digest control the developer gets "An image tag is required (untagged images resolve to :latest)" and a JSON path — true, but it does not name the image, and a Pod with six containers turns that into a hunt. On the init-container fixture the difference is sharper still: the VAP message names alpine:3.18, the actual offending init container, while the Kyverno message quotes the Pod's other image and leaves the path to disambiguate.
That is a real ergonomic win for a control developers hit daily. An admission denial is a piece of developer-facing UX, and the one that names the image is the one that does not generate a ticket.
Where this does not reach
This comparison covers the two controls that are pure expression over the Pod spec. The vulnerability-report gate is not in the table, because it needs to LIST CRDs at admission time — Kyverno's apiCall territory, and not something a VAP expression does. The honest summary is narrower than "VAP replaces Kyverno": for controls that only read the request, native VAP is equivalent, smaller, and gives a better error message.
Next: phase in a binding, not a policy — the rollout mechanism native VAP has and Kyverno does not.