Admission controllers

The gate that mutates and validates every write.

Advanced10 min · lesson 48 of 65
In plain terms
Admission control is the final bouncer past the ID check. It can fix your outfit on the way in (add a wristband) or turn you away for breaking the dress code — right before you actually enter.

You apply a Deployment and it bounces back in half a second: Error from server: admission webhook "validate.kyverno.svc" denied the request: every container needs a memory limit. No Role said no. Nothing was written. That message came from admission control, the checkpoint every write crosses after Kubernetes already knows who you are and that you're allowed, but before it agrees the object exists.

Think of a building's mailroom. Security at the door checked your badge (that's authentication) and confirmed you're cleared for this floor, which is authorization, driven by RBAC (Role-Based Access Control). The mailroom is the last step. It opens every package, sometimes adds a routing label or a required stamp, sometimes refuses to deliver it, and only then files it in the log. Admission control is that mailroom. It runs inside the API server (the front door every kubectl command and every controller talks to), on every create, update, and delete. It's the last place your policy gets a say before the object lands in etcd, the cluster's key-value database.

Mutate first, then validate

Admission runs in two ordered passes, and the order is the whole trick. Mutating controllers go first. They can change the incoming object. One might inject a sidecar (a helper container that rides along next to yours), another might fill in a default memory request, a third might stamp on a label. Next the API server checks the now-modified object against its schema. Then validating controllers run and get a plain yes-or-no vote, accept or reject, no edits allowed. Because mutation always happens before validation, a validating rule always sees the final object, sidecars and defaults included, not the half-formed thing you submitted. One subtlety a busy admin hits eventually: if a mutating webhook edits an object, other mutating webhooks can be re-invoked (that's reinvocationPolicy: IfNeeded), because one plugin's change might make another's rule apply.

Inside the admission stage
1authorized requestpassed RBAC2mutating passinject / default / edit3schema checkvalid object?4validating passaccept or reject5etcdpersisted
Mutating always runs before validating, so a validating rule sees the final object, sidecars and defaults included. Built-in plugins run before your webhooks and CEL policies.
terminal
$ kubectl get validatingwebhookconfigurations
NAME WEBHOOKS AGE
kyverno-resource-validating-webhook-cfg 1 84d
kyverno-policy-validating-webhook-cfg 1 84d
$ kubectl get mutatingwebhookconfigurations
NAME WEBHOOKS AGE
istio-sidecar-injector 1 112d
kyverno-resource-mutating-webhook-cfg 1 84d

The plugins baked into the API server

Most of admission is compiled straight into the API server. These built-in plugins are toggled with the --enable-admission-plugins flag and cover a lot of everyday behavior. NamespaceLifecycle stops you creating objects in a namespace that's being deleted. ResourceQuota enforces per-namespace limits. LimitRanger applies default requests. ServiceAccount wires the identity token into each Pod (a Pod is Kubernetes' smallest unit: one or more containers that share a network identity). NodeRestriction stops a compromised kubelet, the agent on each node, from editing other nodes. PodSecurity enforces the Pod Security Standards. Two of the built-ins are special: MutatingAdmissionWebhook and ValidatingAdmissionWebhook. Those are the plugins that call out to code you supply.

One gotcha reading the flag: it doesn't list everything that's on. It only adds to (or with --disable-admission-plugins, removes from) a default set that's already compiled in and enabled, which includes PodSecurity, ResourceQuota, ServiceAccount, and both webhook plugins. So a flag that shows just NodeRestriction does not mean admission is barely running.

terminal
$ kubectl -n kube-system get pod kube-apiserver-cp-1 \
-o jsonpath='{.spec.containers[0].command}' | tr ' ' '\n' | grep admission
--enable-admission-plugins=NodeRestriction

Adding your own rules

There are two ways to plug in policy the built-ins can't express. The older way is a webhook: you register a ValidatingWebhookConfiguration or MutatingWebhookConfiguration that tells the API server "for every Pod create, POST it to my HTTPS service and do what it says." That service is usually a policy engine like Kyverno or OPA Gatekeeper (OPA = Open Policy Agent), so you write policy as data instead of running your own server. The newer way is built into Kubernetes itself and has been generally available since v1.30. It skips the network hop entirely. ValidatingAdmissionPolicy lets you write the rule in CEL (Common Expression Language, a small language for writing yes-or-no checks) and the API server evaluates it itself. No webhook pod to keep alive, no HTTPS certificate to rotate, no extra failure mode. Webhooks aren't dead, though. They can mutate objects, reach out to external data, and run logic too tangled for a single expression, so plenty of clusters run both side by side.

A policy doesn't have to see every object. namespaceSelector and objectSelector narrow what it matches, and you almost always exclude kube-system so a broken rule can't take down the control plane's own pods. On webhooks, matchConditions (also CEL) can filter finer still, so the backend only gets called for requests that actually matter.

require-memory-limits.yaml
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-memory-limits
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: "object.spec.containers.all(c, has(c.resources.limits) && has(c.resources.limits.memory))"
message: "every container must set a memory limit"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: require-memory-limits-binding
spec:
policyName: require-memory-limits
validationActions: [Deny]
matchResources:
namespaceSelector:
matchLabels: { environment: prod }
terminal
$ kubectl apply -f require-memory-limits.yaml
validatingadmissionpolicy.admissionregistration.k8s.io/require-memory-limits created
validatingadmissionpolicybinding.admissionregistration.k8s.io/require-memory-limits-binding created
$ kubectl label ns prod environment=prod --overwrite
namespace/prod labeled
$ kubectl -n prod run nolimits --image=nginx:1.27
The pods "nolimits" is forbidden: ValidatingAdmissionPolicy 'require-memory-limits'
with binding 'require-memory-limits-binding' denied request:
every container must set a memory limit

When it breaks

The first move when writes start failing is to read the rejection, because it names its own source. A webhook denial reads admission webhook "..." denied the request:. A CEL policy denial names the ValidatingAdmissionPolicy and its binding, exactly like the message above. That tells you which gate fired. Then check that config's failurePolicy and timeoutSeconds, because those two decide what happens when the backing service is slow or unreachable.

terminal
$ kubectl get validatingwebhookconfiguration kyverno-resource-validating-webhook-cfg \
-o jsonpath='{range .webhooks[*]}{.name}{" "}{.failurePolicy}{" "}{.timeoutSeconds}{"\n"}{end}'
validate.kyverno.svc-fail Fail 10

If that config points at a Service with no healthy pods behind it, every matching request waits out the timeout and then failurePolicy decides. Fail rejects it. Ignore lets it through with no policy applied at all. The nastiest version is a fail-closed webhook whose own backend is down: nothing its rules match can be created, including a replacement for the webhook itself, so the outage blocks its own fix. Before touching the config, confirm the backend actually has endpoints with kubectl -n kyverno get endpoints.

Whoever can write webhook configs owns the cluster
A MutatingWebhookConfiguration can inject a container, an environment variable, or a volume into every Pod that gets admitted, silently, cluster-wide. That's exactly how a service mesh (the layer that manages traffic between your services) adds its sidecar, and exactly how an attacker with rights to create webhook configs would backdoor every workload at once. Treat create and update on mutatingwebhookconfigurations and validatingwebhookconfigurations as a top-tier RBAC grant: give it to almost nobody, and alert whenever one of those objects changes.

A dead fail-closed webhook blocks creates cluster-wide. Monitor webhook latency and availability.

Mutating then validating order matters. Never assume a mutate ran if validate failed first in your mental model — learn the chain.

Lab with dry-run and policy audit modes before enforce. Friday evening enforce is how outages start. Monitor webhook latency and availability.

Mutating then validating order matters.

Try this

List validating and mutating webhook configurations. Apply a pod that your policy should reject and capture the deny message from the API.

terminal
$ kubectl get validatingwebhookconfigurations
NAME WEBHOOKS AGE
kyverno-resource-validating-webhook-cfg 1 84d
kyverno-policy-validating-webhook-cfg 1 84d
$ kubectl get mutatingwebhookconfigurations
NAME WEBHOOKS AGE
istio-sidecar-injector 1 112d
kyverno-resource-mutating-webhook-cfg 1 84d
$ kubectl -n kube-system get pod kube-apiserver-cp-1 \
-o jsonpath='{.spec.containers[0].command}' | tr ' ' '\n' | grep admission
--enable-admission-plugins=NodeRestriction
$ kubectl apply -f require-memory-limits.yaml
validatingadmissionpolicy.admissionregistration.k8s.io/require-memory-limits created
validatingadmissionpolicybinding.admissionregistration.k8s.io/require-memory-limits-binding created
$ kubectl label ns prod environment=prod --overwrite
namespace/prod labeled
$ kubectl -n prod run nolimits --image=nginx:1.27
The pods "nolimits" is forbidden: ValidatingAdmissionPolicy 'require-memory-limits'
with binding 'require-memory-limits-binding' denied request:
every container must set a memory limit
$ kubectl get validatingwebhookconfiguration kyverno-resource-validating-webhook-cfg \
-o jsonpath='{range .webhooks[*]}{.name}{" "}{.failurePolicy}{" "}{.timeoutSeconds}{"\n"}{end}'
validate.kyverno.svc-fail Fail 10

Takeaway

Admission mutates and validates after authz. Webhooks extend policy; built-in controllers cover defaults and limits.

Quick check
01A validating webhook from your policy engine is set to failurePolicy: Fail, and its backend pods crash. What happens to kubectl apply of a new Deployment in a namespace the webhook's rules match?
Incorrect — failurePolicy has nothing to do with health probes. It decides what the API server does when it can't get an answer from the webhook, which is precisely the situation here.
Correct — Fail means fail-closed: no answer, no admission. Anything matching the rules is blocked, which is why a fail-closed webhook whose own pods are down can lock you out of the very fix.
Incorrect — There's no auto-fallback. The mode you set is the mode you get. If you want writes to proceed on failure you choose Ignore yourself, and accept that it's a policy bypass.
Incorrect — Admission matches on the config's rules. If those rules include deployments the Deployment write itself is blocked, and even a Pod-scoped rule would block the Pods the Deployment then tries to create.
02A mutating webhook injects a sidecar container into every Pod. A separate validating policy requires that every container set a memory limit. Does the validating policy check the injected sidecar?
Correct — the ordering guarantees validators evaluate the fully mutated object, so an injected sidecar with no memory limit would fail the rule.
Incorrect — validators see the mutated object, not the raw submission, precisely because mutation runs first.
Incorrect — validating webhooks can't edit objects at all; the sidecar came from the mutating pass, which still runs before validation.
Incorrect — an injected sidecar is just another container in the object and is subject to every validating rule.
03You inspect the kube-apiserver command and it shows only --enable-admission-plugins=NodeRestriction. A colleague concludes PodSecurity and ResourceQuota must be disabled. Are they right?
Incorrect — the flag only adds to a default set; it is not the full list of what is running.
Incorrect — webhooks don't re-enable built-in plugins, and these plugins were never off to begin with.
Incorrect — the additive behavior isn't a v1.30 change; the flag has long added to a compiled-in default set.
Correct — --enable-admission-plugins augments the enabled-by-default plugins, so seeing just NodeRestriction doesn't mean the rest are off.

Related