Admission Controllers

An admission controller is code compiled into kube-apiserver that intercepts a request to create, update, or delete an API object after authentication and authorization have passed but before the object is persisted to etcd (Kubernetes — Admission Controllers). Admission runs in two phases: a mutating phase that may rewrite the object, followed by a validating phase that may only accept or reject it. Admission controllers do not see read operations — get, list, and watch bypass the gate entirely, because there is nothing to mutate and nothing to validate against persistence. Admission is the layer where Kubernetes enforces “what may exist in the cluster,” as distinct from authorization’s “what may this user do.” This note covers the built-in (compiled-in) plugins; the two webhook plugins that delegate to external HTTP services are covered in ValidatingAdmissionWebhook and MutatingAdmissionWebhook, and the in-process CEL alternatives in ValidatingAdmissionPolicy and MutatingAdmissionPolicy.

Mental Model

Admission is stages 4 and 6 of the API Server Request Flow: mutating admission sits after authorization, schema validation sits between, and validating admission is the last gate before storage. The crucial conceptual split is who controls it: authentication and authorization answer questions about the caller; admission answers questions about the object. A user with full RBAC permission to create pods can still be blocked by PodSecurity admission if the Pod requests privileged: true in a Restricted-labelled namespace — RBAC said yes, admission said no.

A second key idea: admission controllers are cluster-administrator territory, not tenant territory. The set of enabled plugins is fixed at apiserver startup via flags; a namespace tenant cannot turn one off. The only tenant-facing extensibility is the webhook plugins (whose configurations are API objects) and the CEL policy objects — both of which are themselves gated by the compiled-in MutatingAdmissionWebhook / ValidatingAdmissionWebhook / ValidatingAdmissionPolicy plugins.

flowchart LR
    A[Authenticated +<br/>Authorized request<br/>CREATE/UPDATE/DELETE] --> B[Built-in<br/>mutating plugins<br/>ServiceAccount, Priority,<br/>DefaultStorageClass...]
    B --> C[MutatingAdmissionPolicy<br/>plugin -> in-process CEL]
    C --> D[MutatingAdmissionWebhook<br/>plugin -> external webhooks]
    D --> E[Schema / OpenAPI<br/>validation]
    E --> F[Built-in<br/>validating plugins<br/>PodSecurity, ResourceQuota,<br/>LimitRanger, NamespaceLifecycle...]
    F --> G[ValidatingAdmissionPolicy<br/>plugin -> in-process CEL]
    G --> H[ValidatingAdmissionWebhook<br/>plugin -> external webhooks]
    H --> I[(etcd)]
    style I fill:#e8f0ff

The admission pipeline. The insight: all mutation finishes before any validation begins. A validating plugin always sees the final, fully-mutated object. The webhook and policy plugins are not a separate stage — they are themselves entries in the same ordered plugin list, which is why MutatingAdmissionWebhook appears among the built-in mutators and ValidatingAdmissionWebhook among the built-in validators. In-process CEL policies run before their webhook siblings on each side — per KEP-3962 the mutating chain is “Mutating admission controllers … Mutating admission policies … Mutating admission webhooks (ordered lexicographically by webhook name),” and the validating chain is documented identically on the admission-controllers reference page (sequence diagram caption: “mutation webhooks, followed by validatingadmissionpolicies and finally validating webhooks” — i.e., VAP before VAW).

Mechanical Walk-through

The phase ordering

Within the apiserver, admission plugins are arranged in a fixed registration order. The dispatcher walks the list twice: once collecting every plugin that implements the mutating interface, then once collecting every plugin that implements the validating interface (a few plugins, e.g. ResourceQuota, are validating-only; others like ServiceAccount are mutating-only; the webhook plugins each implement only one side). Concretely the order is:

  1. Built-in mutatorsServiceAccount, DefaultStorageClass, DefaultTolerationSeconds, DefaultIngressClass, Priority, RuntimeClass (its defaulting half), TaintNodesByCondition, etc.
  2. MutatingAdmissionPolicy — evaluates in-process CEL mutation policies (GA in Kubernetes v1.36 per the Mutating Admission Policy reference; see MutatingAdmissionPolicy).
  3. MutatingAdmissionWebhook — calls out to every matching MutatingWebhookConfiguration, ordered lexicographically by webhook name (KEP-3962).
  4. Schema / OpenAPI validation — not an admission plugin, but it runs here, between the phases.
  5. Built-in validatorsLimitRanger, PodSecurity, ResourceQuota, NamespaceLifecycle, NodeRestriction, CertificateApproval, CertificateSubjectRestriction, RuntimeClass (its validating half), etc.
  6. ValidatingAdmissionPolicy — in-process CEL validation (GA 1.30, default-on in 1.36 per the admission-controllers reference).
  7. ValidatingAdmissionWebhook — calls out to every matching ValidatingWebhookConfiguration.

The “policies before webhooks” placement on each side is the explicit upstream design — policies are in-process so they cost essentially nothing per call, and running them first means a request rejected by a CEL policy never incurs the cost of a webhook round-trip.

If any plugin in any phase rejects, the whole request fails immediately with an HTTP error and nothing is persisted. A mutating plugin may also reject (not only modify). Because a later mutating webhook can change the object after an earlier one already ran, the MutatingAdmissionWebhook plugin supports reinvocation — re-running webhooks whose object input changed (see MutatingAdmissionWebhook).

The built-in plugin catalog

The plugins worth knowing, by what they actually do:

  • NamespaceLifecycle (validating) — rejects object creation in a namespace that is being deleted (Terminating) or does not exist, and prevents deletion of the three reserved namespaces default, kube-system, kube-public. This is why a kubectl apply into a half-deleted namespace fails with “namespace is terminating.”
  • ServiceAccount (mutating) — injects the namespace’s default ServiceAccount into Pods that omit one, and adds the projected service-account-token volume so the Pod can authenticate. Without this plugin, Pods would have no in-cluster identity. See ServiceAccount.
  • LimitRanger (mutating + validating) — applies the namespace’s LimitRange defaults to Pods/Containers that omit requests/limits (mutating), then rejects any that exceed the configured min/max bounds (validating). See LimitRange.
  • ResourceQuota (validating) — rejects a create/update that would push a namespace over its ResourceQuota caps (object counts, total CPU/memory requests and limits). See ResourceQuota.
  • PodSecurity (validating) — enforces the Pod Security Standards (Privileged / Baseline / Restricted) according to the namespace’s pod-security.kubernetes.io/enforce label. This is the compiled-in replacement for the removed PodSecurityPolicy. See Pod Security Admission.
  • DefaultStorageClass (mutating) — assigns the cluster’s default StorageClass to a PersistentVolumeClaim that omits storageClassName. See StorageClass.
  • DefaultIngressClass (mutating) — the same idea for Ingress objects and the default IngressClass.
  • Priority (mutating) — resolves a Pod’s priorityClassName to the numeric priority integer the scheduler uses. See Pod Priority and Preemption.
  • DefaultTolerationSeconds (mutating) — adds the node.kubernetes.io/not-ready and node.kubernetes.io/unreachable tolerations with the default 300-second timeout to Pods that lack them — the mechanism behind “Pods are evicted ~5 minutes after a node goes NotReady.”
  • RuntimeClass (mutating + validating) — applies Pod overhead and scheduling constraints declared by a referenced RuntimeClass, and rejects references to non-existent RuntimeClasses.
  • NodeRestriction (validating) — restricts each kubelet to modifying only its own Node object and only the Pods bound to it; works in tandem with the Node authorizer to contain a compromised node.
  • CertificateApproval / CertificateSigning / CertificateSubjectRestriction (validating) — guard the CertificateSigningRequest workflow so a CSR cannot be approved or signed by a principal lacking the right permissions, and block CSRs requesting the privileged system:masters group.
  • MutatingAdmissionWebhook / ValidatingAdmissionWebhook — the two plugins that turn the static compiled-in pipeline into a dynamic, extensible one by delegating to external HTTP webhooks registered as API objects.
  • ValidatingAdmissionPolicy — the plugin that evaluates in-process CEL policy objects.

Configuration / API Surface

Admission is configured at apiserver startup, not via API objects:

kube-apiserver \
  # Add a non-default plugin to the default set:
  --enable-admission-plugins=NamespaceLifecycle,LimitRanger,AlwaysPullImages \
  # Remove a plugin from the default set:
  --disable-admission-plugins=DefaultTolerationSeconds \
  # Optional: per-plugin config (e.g. PodSecurity defaults, ImagePolicyWebhook endpoint):
  --admission-control-config-file=/etc/kubernetes/admission-config.yaml

Line-by-line:

  • --enable-admission-plugins — plugins listed here are turned on in addition to the compiled-in default-on set. It does not replace the defaults.
  • --disable-admission-plugins — plugins listed here are turned off, even if they are default-on. Disabling ServiceAccount or NamespaceLifecycle is almost always a mistake.
  • The default-on set as of Kubernetes 1.36, verbatim from the admission-controllers reference, is: CertificateApproval, CertificateSigning, CertificateSubjectRestriction, DefaultIngressClass, DefaultStorageClass, DefaultTolerationSeconds, LimitRanger, MutatingAdmissionWebhook, NamespaceLifecycle, PersistentVolumeClaimResize, PodSecurity, Priority, ResourceQuota, RuntimeClass, ServiceAccount, StorageObjectInUseProtection, TaintNodesByCondition, ValidatingAdmissionPolicy, ValidatingAdmissionWebhook. Notably this list does not include MutatingAdmissionPolicy even though the dedicated Mutating Admission Policy reference page marks it Kubernetes v1.36 [stable] (enabled by default). The two upstream docs pages disagree as of this writing (see uncertainty callout below).
  • --admission-control-config-file — points at a config file giving per-plugin parameters; the canonical use is supplying PodSecurity’s cluster-wide default level and exemptions.

Inspect what is active:

# The set of enabled plugins is reported in apiserver's verbose health output
kubectl get --raw='/livez?verbose' | grep -i admission
 
# Dynamic webhook configurations (the tenant-visible part)
kubectl get mutatingwebhookconfigurations
kubectl get validatingwebhookconfigurations
kubectl get validatingadmissionpolicies

Resolved (2026-05-30)

MutatingAdmissionPolicy is compiled-in as default-on in Kubernetes 1.36. Verified directly against pkg/kubeapiserver/options/plugins.go at release-1.36 — note the file moved from the older cmd/kube-apiserver/app/options/plugins.go path. mutatingadmissionpolicy.PluginName is registered in AllOrderedPlugins and appears in the defaultOnPlugins set inside DefaultOffAdmissionPlugins(); since that function returns AllOrderedPlugins − defaultOnPlugins, presence in the default-on set means the plugin is not in the default-off set — i.e., it is default-on. Activation is additionally gated by the MutatingAdmissionPolicy feature gate, which is GA (locked-on) in 1.36 per KEP-3962. The admission-controllers reference page omitting MutatingAdmissionPolicy from its default-on list and its “extension points” section is therefore a documentation-currency bug, not a behavior difference. (Caveat: the default-on set drifts each minor — for any specific cluster, verify against plugins.go at that minor’s tag.)

Failure Modes

  • Disabled ServiceAccount plugin. Pods created afterward have no token volume and no in-cluster identity; every in-Pod API call becomes anonymous. Symptom: 403 from controllers running in those Pods.
  • Webhook plugin enabled, webhook unreachable. Because MutatingAdmissionWebhook/ValidatingAdmissionWebhook are themselves compiled-in plugins, you cannot disable them per-cluster to escape a misbehaving external webhook — you must fix or delete the offending WebhookConfiguration object. See the cluster-wedge failure mode in ValidatingAdmissionWebhook.
  • ResourceQuota race. Because quota is enforced at admission and quota usage is updated by a controller asynchronously, bursts of concurrent creates can briefly over-admit before the usage counter catches up; the apiserver then rejects the next request. Symptom: intermittent exceeded quota under high create concurrency.
  • NamespaceLifecycle and stuck finalizers. A namespace stuck Terminating because a finalizer never clears will reject all new objects in it — a common “I can’t deploy and I don’t know why” incident.
  • Managed-service lockout. EKS, GKE, and AKS do not expose --enable-admission-plugins. If you need a non-default built-in plugin (e.g. AlwaysPullImages), you must instead implement the policy with a webhook or a CEL policy.

Alternatives and When to Choose Them

  • Built-in plugin vs. webhook vs. CEL policy. Prefer a built-in plugin when one exists for your need — it is in-process, cannot fail open, and needs no extra deployment. Use a ValidatingAdmissionPolicy (or MutatingAdmissionPolicy) when the rule fits a CEL expression: still in-process, no certificate management, no network hop. Reach for a webhook (ValidatingAdmissionWebhook / MutatingAdmissionWebhook) only when the policy needs arbitrary logic or external data (image-signature lookups, calls to an external system).
  • Admission vs. runtime enforcement. Admission catches a bad object before it exists; runtime tools like Falco catch bad behavior after the workload is running. They are complementary, not substitutes — admission is cheaper and should be the first line.

Production Notes

  • The standard guidance (Kubernetes — Admission Webhook Good Practices) is: keep the default plugin set, and express custom policy through ValidatingAdmissionPolicy (GA 1.30) / MutatingAdmissionPolicy (GA 1.36) first, falling back to webhooks (Gatekeeper, Kyverno) only for what CEL cannot express. The “CEL first, webhooks last” preference is grounded in cost and reliability — a CEL policy runs in-process with no network hop and no fail-open vs. fail-closed dilemma, whereas a webhook adds latency to every matching request and forces an explicit failurePolicy choice that can wedge the cluster either way.
  • Policy engines OPA Gatekeeper and Kyverno are not admission controllers in the compiled-in sense — they are external webhook servers reached through the Validating/MutatingAdmissionWebhook plugins. Confusing “admission controller” with “policy engine” is a common interview slip.
  • Because the plugin order is fixed in source, dependency-sensitive logic (e.g. a webhook that must run before PodSecurity) is constrained: webhooks always run before the built-in validators, so a webhook can never observe a PodSecurity rejection — it must reject independently.

See Also