Every kubectl apply on a Pod guarded by disallow-latest-tag today travels over the network to a separate Kyverno or Gatekeeper pod and back — for every single resource, every single time, for as long as the cluster is alive. ValidatingAdmissionPolicy (VAP) removes that hop: the CEL expression is compiled and evaluated right inside kube-apiserver, with no external webhook involved. This isn’t experimental — VAP has been stable since Kubernetes 1.30, and Kyverno 1.17 is already deprecating classic ClusterPolicy in favor of CEL-native ValidatingPolicy. Let’s break down when VAP fully replaces the webhook, and when you still need one.
Table of contents
Open Table of contents
Why this can’t wait until 2026
VAP followed the usual Kubernetes maturation path: alpha in 1.26, beta in 1.28, GA in 1.30. That means it’s already available out of the box, with no feature gate, on most production clusters. In parallel, a second and more practically important shift is happening: Kyverno is officially winding down its old policy model. Starting with 1.17, ClusterPolicy and CleanupPolicy are marked deprecated in favor of CEL-native ValidatingPolicy and DeletingPolicy, with removal planned for v1.20 (October 2026, source). If you’re writing and maintaining Kyverno policies today, migrating to CEL isn’t a “try it someday” item — it’s a couple of releases away.
Latency isn’t an abstract dashboard metric here. Every webhook sits on the critical path of kubectl apply, helm upgrade, and controller reconciliation loops — the same path CI/CD waits on before it calls a deploy successful. Even if the Kyverno pod itself responds in single-digit milliseconds, you’re still paying for a TLS handshake, serializing the object to JSON and back, and the risk that the pod happens to be unavailable exactly when the cluster is under peak load.
The difference in the diagram isn’t cosmetic. The webhook path requires the controller’s pod to be Ready, complete a TLS handshake, and respond within timeoutSeconds — otherwise failurePolicy decides (Fail rejects the request, Ignore lets it through unchecked, which in practice means a hole in your guardrails at exactly the moment you need them most). CEL in the API server knows none of that: the rule is compiled to bytecode at kubectl apply time and runs in the same process that already holds the object in memory for the rest of the admission chain.
The CEL expression model: what a rule can see
ValidatingAdmissionPolicy exposes four named variables to a CEL expression — and they determine what you can actually write in expression, which is worth understanding before porting your first real policy.
object— the full incoming resource, the same JSON that would be written to etcd. The main variable for almost every validation rule: resource limits, required labels, forbidden fields.oldObject— the pre-change version of the resource onUPDATE;nullonCREATE. Needed for rules like “don’t allow changingspec.storageClassNameafter a PVC is created” or “don’t allowreplicasto drop more than 50% in a single update” — without it, that kind of comparison simply can’t be expressed.params— an optional parameter object referenced viaparamRefon theValidatingAdmissionPolicyBinding. Lets you keep oneValidatingAdmissionPolicyand vary limits per namespace through different parameter CRs — a direct analogue of Gatekeeper’sConstraint, minus the second language.variables— named sub-expressions underspec.variables, computed once and reused across multiplevalidations. Useful when the same expensive expression — say, filtering a container list by some condition — is needed in three rules at once: withoutvariables, CEL would recompute it three times per admission request.
Scope is defined separately from logic — in matchConstraints.resourceRules (which apiGroups, resources, operations) and optionally in objectSelector/namespaceSelector (by labels). It’s the same principle as match in Kyverno or matchLabels on a Gatekeeper Constraint, just closer to the native Kubernetes API.
Every expression in validations[].expression must return a bool. There’s no “execution with side effects” — the CEL compiler checks types at kubectl apply time, not on the first request that happens to hit the rule, so a typo in a field name gets caught immediately. Better still, starting with 1.30 the API server can type-check expressions against installed CRDs and surface potential problems right in the policy’s status.typeChecking — catching “this field doesn’t exist on this type” before the rule silently lets everything through or blocks something it shouldn’t.
Kyverno ValidatingPolicy vs a raw ValidatingAdmissionPolicy
It’s easy to confuse two different things that both run CEL under the hood.
A raw ValidatingAdmissionPolicy is a native Kubernetes API — no Kyverno, no Gatekeeper, no controller pod in the cluster at all. You write the CEL by hand, apply it with kubectl, and debug via kubectl describe and API server logs. Full control, zero extra components and zero extra failure points — but also none of the usual conveniences: no kyverno test for local CI runs, no PolicyReport with a cluster-wide violation summary, no shared library of ready-made rules for this exact format — everything is written from scratch or copied out of someone else’s repo.
Kyverno’s ValidatingPolicy — the CEL-native CRD that replaces ClusterPolicy — is a layer on top of the same CEL model, but with Kyverno’s familiar tooling around it: kyverno test for running a policy against fixtures in CI, PolicyReport for aggregated cluster-wide audit results, one CLI for debugging. The key detail: Kyverno can automatically generate a native ValidatingAdmissionPolicy and ValidatingAdmissionPolicyBinding from a ValidatingPolicy whenever the rule fits within what CEL can express, and still routes the request through its own webhook when the rule goes beyond that. You don’t have to decide this per policy — the engine picks the execution path for you, transparently.
A webhook stays mandatory in exactly three cases that CEL can’t express in principle — not because nobody bothered to implement them:
- mutation — CEL inside
ValidatingAdmissionPolicycan only allow or deny an object, never change it; a separate CEL mechanism for mutations (MutatingAdmissionPolicy) exists, but it’s noticeably younger and less capable than Kyverno’s mutation DSL; - generation — creating companion resources (a
NetworkPolicyon every newNamespace, aResourceQuotafrom a template) — CEL has no side effects: it only reads the object and returns abool, it creates nothing; - external calls — verifying an image signature via
cosign, calling out to an external reputation service or an allowed-registry list — CEL is synchronous and can’t wait on a network response; everything has to fit inside the request object itself.
Comparison table
| Dimension | Webhook (Kyverno ClusterPolicy) | CEL (ValidatingAdmissionPolicy) |
|---|---|---|
| Where it runs | separate pod, network call | inside kube-apiserver |
| Admission latency | network + serialization on every request | in-process, no network hop |
| Failure point | pod must be Ready, failurePolicy decides | no separate component that can go down |
| Mutation / generation | ✅ | ❌ (needs a webhook or MutatingAdmissionPolicy) |
| External calls (cosign, API) | ✅ | ❌ |
| Type checking of a rule | at runtime | statically, on apply (status.typeChecking) |
| Local testing | kyverno test, PolicyReport | kubectl, API server logs (same kyverno test inside Kyverno’s wrapper) |
| Parameterization | Kyverno variables | params + paramRef |
| Requires a cluster component | yes (Kyverno/Gatekeeper pod) | no |
What it takes to move a policy to CEL
Let’s take disallow-latest-tag — the same policy covered in the Kyverno vs OPA Gatekeeper comparison — and rewrite it as a native ValidatingAdmissionPolicy with zero Kyverno pods in the cluster.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: disallow-latest-tag
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
namespaceSelector:
matchExpressions:
- key: kubernetes.io/metadata.name
operator: NotIn
values: ["kube-system"]
validations:
- expression: >
object.spec.containers.all(c, !c.image.endsWith(':latest'))
message: "The :latest tag is not allowed. Pin an exact tag or a SHA digest."
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: disallow-latest-tag-binding
spec:
policyName: disallow-latest-tag
validationActions: ["Deny"]
ValidatingAdmissionPolicy describes the rule, its scope, and its exclusions (here, kube-system, where system images often ship on a different tagging cadence), while ValidatingAdmissionPolicyBinding turns the rule on and sets its mode (Deny blocks the request, Warn returns a warning to the client but lets it through, Audit only records an AdmissionReview entry with no effect on the request). Splitting this into two objects lets you keep one rule with several bindings set to different validationActions per environment — for example, Audit in staging until you’re sure the rule doesn’t produce false positives, and Deny in production.
kubectl apply -f disallow-latest-tag-vap.yaml
kubectl get validatingadmissionpolicies
# NAME AGE
# disallow-latest-tag 12s
kubectl get validatingadmissionpolicy disallow-latest-tag \
-o jsonpath='{.status.typeChecking.expressionWarnings}'
# (empty — the CEL expression passed type checking with no warnings)
The latency difference is visible without a dedicated benchmark rig — just measure the time between sending a request and getting the API server’s response, before and after the migration:
for i in {1..20}; do
t0=$(date +%s%N)
kubectl run "probe-$i" --image=nginx:1.27.0 --restart=Never --dry-run=server >/dev/null
t1=$(date +%s%N)
echo "$(( (t1 - t0) / 1000000 )) ms"
done
--dry-run=server runs the request through the full admission path, including the policy, without actually creating the resource — convenient for looping probes without littering the namespace with test pods. On a local kind cluster, Kyverno’s webhook path adds a noticeable network hop to every request; with CEL in the API server, that hop disappears entirely, and latency drops from tens of milliseconds to single digits. The exact numbers depend on the network, node resources, and load on the controller pod, but the direction holds in any environment: fewer network hops means lower latency, and the spread (p99) tightens more visibly than the median.
How to verify it works
kubectl run test --image=nginx:latest --restart=Never
# Error from server: ValidatingAdmissionPolicy 'disallow-latest-tag' with binding
# 'disallow-latest-tag-binding' denied request: The :latest tag is not allowed.
# Pin an exact tag or a SHA digest.
kubectl run ok --image=nginx:1.27.0 --restart=Never
# pod/ok created
kubectl run legacy --image=nginx:latest --restart=Never -n kube-system
# pod/legacy created — namespaceSelector excluded kube-system, as intended
The error message comes straight from the API server, with no mention of a webhook or a controller pod — the policy is now part of the Kubernetes API itself, not an external layer bolted on top of it. If you want to see how many requests a rule would have blocked before flipping it to Deny in production, start with validationActions: ["Audit"] and watch the structured AdmissionReview entries in the API server’s audit log — the same mechanism Kyverno itself uses to populate PolicyReport, minus the extra CRD in between.
Bottom line
For simple validation rules — a forbidden tag, a required label, CPU limits being set — CEL in the API server is now the new default: lower latency, fewer moving parts, fewer reasons to keep failurePolicy: Ignore in production “just in case.” Kyverno still earns its place where CEL fundamentally can’t reach: mutation, resource generation, and checks that need an external call like verifying an image signature. The sane path isn’t picking one tool forever — it’s migrating validation rules as Kyverno deprecates ClusterPolicy across 1.17–1.20, and keeping the webhook only for what CEL genuinely can’t do.