About three years ago one of my clients called me at 7 AM. Their Kubernetes cluster had gone completely dark. No new pods, no deployments, nothing. It turned out their OPA Gatekeeper webhook had lost its TLS certificate overnight. The admission webhook was configured to Fail (the safe default), so the API server refused every mutating request cluster-wide until the cert was renewed. Their entire engineering team spent four hours firefighting something that was supposed to make the cluster more secure.
That is the admission webhook problem in one story. Webhooks are powerful, but they add a dependency outside the API server that must be healthy for your cluster to function. The certificate must be valid. The webhook pod must be running. The network path must be clear. Get any of that wrong and you have a production incident.
With twenty years of building and operating cloud infrastructure, I have watched the Kubernetes ecosystem slowly learn that the right answer to “enforce policies on this cluster” is not always “add another external system.” ValidatingAdmissionPolicy, which reached GA in Kubernetes 1.30 and runs CEL expressions in-process inside the API server itself, is the answer Kubernetes should have had from day one.
What ValidatingAdmissionPolicy Actually Is
ValidatingAdmissionPolicy (VAP) is a native Kubernetes API object that lets you write admission control logic using CEL expressions that execute directly inside the kube-apiserver process. No webhook server, no external controller, no certificate to manage. When a resource hits the admission phase, the API server evaluates your CEL expressions inline and either allows or rejects the request.
The key design is separation between two objects: the policy itself and a binding. The ValidatingAdmissionPolicy defines the rules. A ValidatingAdmissionPolicyBinding says “apply this policy to these namespaces, resource types, or namespace selectors.” You can reuse one policy across many bindings, passing different parameters to each, which is how you build genuinely reusable policy libraries.
Here is what that looks like in practice:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-resource-limits
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >
object.spec.containers.all(c,
has(c.resources) &&
has(c.resources.limits) &&
has(c.resources.limits.memory) &&
has(c.resources.limits.cpu)
)
message: "All containers must have CPU and memory limits set."
And the binding that activates it:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-resource-limits-binding
spec:
policyName: require-resource-limits
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels:
environment: production
That is it. No webhook deployment, no TLS secret, no RBAC for a controller service account. Just two YAML objects. The policy blocks any pod in a namespace labeled environment: production that does not set explicit CPU and memory limits. The Deny action means violations are rejected immediately. If you use Audit instead, violations get written to the API server audit log without blocking the request, which is how you roll out new policies safely.

CEL: The Expression Language That Changes Everything
CEL is Google’s Common Expression Language. You have already seen it in Firebase Security Rules, Google Cloud IAM conditions, and Kubernetes’ own admission webhooks use it for matchConditions. The syntax is deliberately limited. There are no loops, no I/O, no recursion. That is a feature, not a limitation. The API server can evaluate CEL expressions in microseconds with predictable performance, which is exactly what you need at admission time.
The key built-ins you will use constantly:
has(field) tests whether an optional field is present. Use this before accessing nested fields to avoid nil pointer panics. The expression has(object.spec.securityContext) safely checks the field exists before you dig into it.
.all(var, condition) iterates over a list and requires the condition to be true for every element. Checking that every container has a non-root user ID looks like this:
object.spec.containers.all(c,
has(c.securityContext) &&
has(c.securityContext.runAsNonRoot) &&
c.securityContext.runAsNonRoot == true
)
.exists(var, condition) returns true if any element matches. Useful for checking whether at least one container has a particular property.
.filter(var, condition).size() counts matching elements. I use this when I need to assert exactly N containers have a specific annotation or label.
oldObject is available during UPDATE operations and holds the previous state. This lets you enforce immutability rules like “the app label cannot change after a Deployment is created”:
request.operation != "UPDATE" ||
object.metadata.labels["app"] == oldObject.metadata.labels["app"]
authorizer.group('').resource('secrets').check('get').allowed() lets you check RBAC permissions from within a CEL expression. This is genuinely novel. You can write policies that behave differently based on who is making the request.
One thing that trips people up: CEL in VAP does not have access to Kubernetes objects beyond the one being admitted. You cannot say “look up all other pods in this namespace and count them.” For policies that require cross-object awareness, you still need OPA Gatekeeper or Kyverno. That constraint is by design and is what keeps CEL evaluation fast.
Parameterized Policies
The real power of VAP comes from parameterized policies using ParamKind. You define a policy that references a parameter object, then each binding provides different parameters. This is how you build a single “require labels” policy that different teams can configure independently.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-labels
spec:
paramKind:
apiVersion: v1
kind: ConfigMap
matchConstraints:
resourceRules:
- apiGroups: ["apps"]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["deployments"]
validations:
- expression: >
params.data.requiredLabels.split(",").all(label,
has(object.metadata.labels) &&
label in object.metadata.labels
)
messageExpression: >
"Missing required labels: " + params.data.requiredLabels
Now you create a ConfigMap with the labels each team requires, and a binding that points to it:
apiVersion: v1
kind: ConfigMap
metadata:
name: platform-team-labels
namespace: policy-params
data:
requiredLabels: "team,cost-center,environment"
Each binding can point to a different ConfigMap. The payment team requires team,cost-center,environment,pci-scope. The data team requires team,data-classification. One policy handles every case.

Audit Mode: Rolling Out Policies Without Breaking Production
This is where most teams get the operational story wrong. The instinct is to immediately set validationActions: [Deny] and see what breaks. Do not do this.
The right workflow is: deploy with Audit, watch the audit log for a week, fix violations, then promote to Deny. VAP makes this explicit in the binding spec.
spec:
validationActions: [Audit]
Violations appear in the API server audit log with auditAnnotations you can read with any log analysis tool. You can also define custom annotations in the policy to surface exactly what violated:
auditAnnotations:
- key: "blocked-registry"
valueExpression: >
"Container " + c.name + " uses registry " + c.image.split("/")[0]
I spent two weeks at one client doing nothing but running VAP in audit mode across their forty-namespace production cluster before we turned on enforcement. We found 200-plus policy violations, most of them in legacy Helm charts that had never been updated to set resource limits. If we had deployed in enforce mode, we would have blocked a deploy during a critical business window and created exactly the kind of operational crisis we were trying to prevent.
The pattern I recommend for every new policy:
- Deploy with
validationActions: [Audit]in a binding that targets all namespaces - Query your audit log for violations for at least a week
- Fix violations in workloads and templates
- Create a new binding with
validationActions: [Deny]that targets production namespaces only - Leave the audit binding running on dev/staging indefinitely
This gives you a permanent early-warning system for new violations without ever risking a production deploy failure.
MutatingAdmissionPolicy: The Newer Half of the Story
ValidatingAdmissionPolicy validates (read-only). MutatingAdmissionPolicy, which reached GA in Kubernetes 1.36, mutates objects in-flight using CEL expressions and either server-side apply merge patches or JSON patches.
This replaces a common category of mutating webhooks: inject a default label, set runAsNonRoot: true if missing, add an annotation for auditing. The same operational benefits apply. No webhook server, no TLS cert, runs in-process.
A simple example that sets automountServiceAccountToken: false on pods that do not explicitly configure it:
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingAdmissionPolicy
metadata:
name: default-no-automount-token
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE"]
resources: ["pods"]
mutations:
- patchType: ApplyConfiguration
applyConfiguration:
expression: >
Object{
spec: Object.spec{
automountServiceAccountToken:
!has(object.spec.automountServiceAccountToken)
? false
: object.spec.automountServiceAccountToken
}
}
The ApplyConfiguration patch type merges your CEL-produced object with the incoming resource using server-side apply semantics, which is the cleanest model. The JSONPatch type gives you the full RFC 6902 patch DSL for more complex cases, though CEL is expressive enough that most mutations fit in ApplyConfiguration cleanly.
VAP vs OPA Gatekeeper vs Kyverno: When to Use Each
I get this question on almost every engagement now. My answer: VAP for the 80% of cases that are straightforward, OPA or Kyverno for the cases that require cross-object awareness or the complex policy library you have already built.
The existing policy-as-code-opa-kyverno article on this site covers OPA and Kyverno in depth. The decision tree I use is:
Use VAP when:
- Your policy operates only on the object being admitted
- You want zero external dependencies in your admission control path
- You are starting fresh and have not yet invested in OPA or Kyverno
- You need audit mode with zero infrastructure overhead
- You want policies stored as plain Kubernetes objects (auditable via standard RBAC and
kubectl)
Use OPA Gatekeeper when:
- You need to reference existing Kubernetes objects (e.g., “count how many pods already exist with this label”)
- You have an existing library of Rego policies that work correctly
- You need the Rego audit results surfaced as Constraint objects with status conditions (the Gatekeeper audit loop model)
Use Kyverno when:
- You want a YAML-native policy language with less learning curve than Rego
- You need deep mutation support including pattern-based transforms
- You are working on a team that is not comfortable with CEL or functional expression styles
- You need Kyverno’s generate rules (creating companion resources on object creation)
Do not use OPA or Kyverno when:
- You are using them only for things VAP can do natively in your cluster version
- The webhook is deployed on a single node without pod disruption budgets (this is how you get the 7 AM incident I opened with)
One real pattern I see frequently: run VAP for basic admission guardrails and run Kyverno’s generate controller separately for side-effect policies like “create a default NetworkPolicy when a new namespace is created.” The admission path stays simple; the generation path runs asynchronously.
Policy as Code in GitOps Workflows
Because VAP objects are standard Kubernetes resources, they fit naturally into GitOps workflows. You store them in Git, ArgoCD or Flux syncs them, and you get complete auditability of every policy change via git history. This is a clean improvement over webhook-based tools where the webhook server configuration and the policy code live in different places. If you are already running a GitOps setup, adding VAP policies is as simple as dropping YAML into your policy folder. Pairing this with the GitOps workflow in our ArgoCD and Flux guide gives you a fully auditable policy lifecycle.
Because VAP objects surface in the API server audit log, you also get compliance evidence automatically. Every policy evaluation, every violation, every enforcement action gets timestamped and attributed to the user who triggered it. For teams working toward SOC 2 or PCI-DSS, this kind of native audit trail is worth a lot.
Production Hardening Checklist
After running VAP in production across several large clusters, here is what I have found matters:
Pin your matchConstraints tightly. A policy that matches resources: ["*"] will evaluate against every API object in the cluster. For expensive CEL expressions or high-churn resources like Events, this adds measurable latency to the API server. Match only what you need to enforce.
Use variables to avoid recomputing the same expression. VAP supports a variables block where you define named intermediate values:
spec:
variables:
- name: containers
expression: "object.spec.containers + object.spec.initContainers"
validations:
- expression: >
variables.containers.all(c, has(c.resources.limits.memory))
This makes complex policies readable and avoids evaluating the same sub-expression repeatedly.
Test with cel-policy-testing before deploying. There is an emerging ecosystem of CEL testing tools and the official kubectl apply --dry-run=server works as a quick sanity check: if the policy is active and your test manifest would be denied, the dry-run will fail with the rejection message.
Set failurePolicy: Ignore during rollout. For the initial rollout of any VAP, I set the policy’s failurePolicy to Ignore (meaning if the CEL evaluation itself errors, the request is allowed). This is not the same as audit mode on the binding, but it provides an extra safety valve. Once you are confident the CEL expression is correct, switch to failurePolicy: Fail.
Layer VAP with RBAC. VAP does not replace Kubernetes RBAC. RBAC controls who can do what. VAP controls what valid resources look like. Both layers are necessary. A principal with create pods permission still hits VAP, so your resource-limits policy applies even to platform engineers running one-off debugging pods.
Integrate with Falco for runtime. VAP enforces at admission time, which means it only catches problems at create/update. Runtime threats like a container exec-ing into a root shell, writing to restricted paths, or making unexpected syscalls require a tool like Falco or other runtime security controls. VAP and runtime security are complementary layers, not substitutes.

Common Policies Worth Implementing Today
Here are the five VAP policies I recommend every cluster should have. These cover the most common CVEs and compliance findings I encounter in Kubernetes security reviews.
Block root containers:
object.spec.containers.all(c,
has(c.securityContext) &&
has(c.securityContext.runAsNonRoot) &&
c.securityContext.runAsNonRoot == true
)
Restrict image registries to approved sources:
object.spec.containers.all(c,
c.image.startsWith("registry.internal.myco.com/") ||
c.image.startsWith("gcr.io/myco-prod/")
)
Block privileged containers:
object.spec.containers.all(c,
!has(c.securityContext) ||
!has(c.securityContext.privileged) ||
c.securityContext.privileged == false
)
Require pod topology spread constraints (to prevent all replicas landing on one node):
!has(object.spec.topologySpreadConstraints) ||
object.spec.topologySpreadConstraints.size() > 0
Block hostPath volume mounts:
!has(object.spec.volumes) ||
object.spec.volumes.all(v, !has(v.hostPath))
All five fit in a single Git repository directory, sync via GitOps in under a minute, and enforce with zero external infrastructure. That is roughly equivalent to the admission control that takes a team several days to configure with Gatekeeper or Kyverno.
Migration Path From Webhook-Based Tools
If you are running Kyverno or OPA Gatekeeper today, I would not do a big-bang migration. The approach that works:
- Identify your twenty most common, simple policies. These are the candidates to migrate to VAP.
- Translate each one to CEL and deploy in audit mode alongside your existing tool.
- Confirm the audit log shows the same violations your existing tool catches.
- Once confirmed, switch the VAP binding to Deny and remove the old policy from Kyverno/OPA.
- Leave complex cross-object policies in their original tool indefinitely.
You end up with a hybrid setup: VAP for the straightforward cases, Kyverno or OPA for the cases that genuinely need them. This is not a compromise. This is the right architecture. Tool sprawl is the real enemy. Using the right tool for each policy type is better than forcing everything through one abstraction. For broader context on how admission control fits into Kubernetes security posture, our zero-trust security guide covers the layered approach in detail, and the sandbox isolation article covers what admission control cannot protect you from at the container runtime layer.
What I Actually Recommend
If you are running Kubernetes 1.30 or later, ValidatingAdmissionPolicy should be your default choice for new cluster policies. The operational simplicity is real. No webhook to babysit, no cert to rotate, no controller to version. Policies are just YAML objects that live in your cluster alongside everything else.
The architecture fit is clean too. You are not adding another component to a system that already has too many moving parts. You are using a mechanism the API server already provides. That matters at 3 AM when you are debugging a cluster and you would rather not be thinking about whether the OPA pod is healthy.
The limitation worth respecting: CEL cannot reach out to other Kubernetes objects. If your policy needs to know “how many secrets already exist in this namespace” or “does this namespace have a NetworkPolicy,” you need Gatekeeper or Kyverno. That is a real constraint, and it affects maybe 20% of the policies I have seen teams need. But that 80%/20% split means there is a lot of value available to anyone willing to invest two hours learning CEL.
MutatingAdmissionPolicy extends the story for teams that have webhook-based defaulting policies. If you are using a webhook to inject default labels, fix missing security contexts, or add cost-center annotations, those are now first-class Kubernetes objects with audit trails, GitOps-friendly storage, and zero external service dependencies.
Twenty years of running production Kubernetes (and its predecessors in the Mesos and raw container era) has taught me that the clusters that stay stable are the ones with fewer moving parts, not more. ValidatingAdmissionPolicy is one of the clearest wins in recent Kubernetes releases: it takes something that used to require external infrastructure and makes it native. That is worth deploying this week.
Get Cloud Architecture Insights
Practical deep dives on infrastructure, security, and scaling. No spam, no fluff.
By subscribing, you agree to receive emails. Unsubscribe anytime.
