Skip to content

Admission Control & Policy

The API Machinery traced a write request through its gauntlet: authentication answers who are you, authorization answers may you do this. But “may you create a Pod” is a coarse question. It can’t ask what kind of Pod — whether it runs as root, mounts the host filesystem, pulls from an untrusted registry, or omits the resource limits that keep a node alive. Those are properties of the object, not the verb, and RBAC has no vocabulary for them.

Admission control is the stage that does. It sits after authz and before the object is persisted to etcd, and it’s the only place in the request path that can both change an object (add a default, inject a sidecar) and reject it on the basis of its contents. If RBAC is the bouncer checking your ID at the door, admission control is the dress code enforced just inside it.

Every mutating API request — create, update, delete — flows through the same ordered pipeline inside the kube-apiserver. Admission runs in two passes, and the order is not arbitrary.

kubectl apply pod.yaml
┌──────────┐ ┌──────────┐ ┌─────────────────── ADMISSION ──────────────────┐ ┌─────────┐
│ AUTHN │──►│ AUTHZ │──►│ 1. MUTATING 2. object 3. VALIDATING │──►│ etcd │
│ who? │ │ may you? │ │ admission ──► schema ──► admission │ │ persist │
└──────────┘ └──────────┘ │ (can change it) validation (yes/no only) │ └─────────┘
└──────────────────────────────────────────────────┘
defaults added, is it well- policy enforced,
sidecars injected formed YAML? no further edits

Two passes, in this order, for a precise reason: mutation must finish before validation begins. A mutating webhook might inject a sidecar or set a default securityContext; a validating webhook then judges the final object that will actually be stored. If they interleaved, a validator could approve an object that a later mutator changes out from under it. So the apiserver runs all mutators first (with a re-run if any mutator changes the object, to give every mutator a fresh view), then runs all validators against the settled result. Between them, the object is checked against its OpenAPI schema — the structural validation that rejects a replicas: "four" regardless of policy.

Long before webhooks, the apiserver shipped with a fixed set of compiled-in admission plugins, enabled via the --enable-admission-plugins flag. They’re not optional bolt-ons; several are load-bearing parts of how the cluster behaves. A few worth knowing:

ControllerPassWhat it does
NamespaceLifecyclevalidatingRejects objects in a namespace that’s being deleted or doesn’t exist
LimitRangermutatingApplies a namespace’s LimitRange defaults to Pods that omit requests/limits
ResourceQuotavalidatingRejects a create that would exceed a namespace’s ResourceQuota
ServiceAccountmutatingAuto-mounts the default ServiceAccount token into Pods (see Security in Depth)
DefaultStorageClassmutatingStamps the default StorageClass onto a PVC that names none (see Storage Internals)
PodSecurityvalidatingEnforces Pod Security Standards — covered below
MutatingAdmissionWebhookmutatingCalls out to your webhooks
ValidatingAdmissionWebhookvalidatingCalls out to your webhooks

The last two are the extension points. They’re themselves built-in controllers whose whole job is to fan out to webhook servers you register — which is how the fixed pipeline becomes infinitely extensible without recompiling the apiserver.

A webhook lets you run arbitrary policy logic in a server you control. You register it with a MutatingWebhookConfiguration or ValidatingWebhookConfiguration object; the apiserver then POSTs an AdmissionReview to your endpoint for every matching request and blocks until you answer.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: require-resource-limits
webhooks:
- name: limits.policy.example.com
rules: # which requests to intercept
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
clientConfig:
service: { name: policy-webhook, namespace: policy, path: /validate }
caBundle: <base64 CA cert> # apiserver verifies the webhook's TLS cert
admissionReviewVersions: ["v1"]
sideEffects: None # honest declaration: a dry-run won't change anything
timeoutSeconds: 5
failurePolicy: Fail # if the webhook is down, REJECT the request

The apiserver sends an AdmissionReview carrying the object; your server replies with the same kind, setting allowed: true|false (and, for a mutating webhook, a base64 JSON Patch in patch):

apiserver ──POST AdmissionReview{request:{object: <pod>}}──► your webhook
your webhook ──AdmissionReview{response:{allowed:false, ──► apiserver
status:{message:"resource limits required"}}}

Three fields in the registration are where production incidents are born:

  • failurePolicyFail (the default and the safe choice) rejects requests when your webhook is unreachable; Ignore admits them. Fail means a crashed webhook can halt all Pod creation in the cluster. Scope your rules and namespaceSelector tightly so an outage blasts as little as possible.
  • sideEffects — declare whether processing a request changes external state. Setting None lets the apiserver call you during a dry-run safely; lying here breaks kubectl apply --dry-run=server.
  • timeoutSeconds — every webhook adds latency to the write path. The apiserver waits for you on every matching request, so a slow webhook slows the whole cluster.

ValidatingAdmissionPolicy: CEL without a server

Section titled “ValidatingAdmissionPolicy: CEL without a server”

Running, scaling, and securing a webhook server just to enforce a rule is a lot of operational weight for “reject Pods without resource limits.” So Kubernetes added an in-process alternative: ValidatingAdmissionPolicy, which expresses the rule in CEL (the Common Expression Language) and evaluates it inside the apiserver — no network hop, no server to run, no failurePolicy outage to fear.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: require-limits
spec:
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >-
object.spec.containers.all(c, has(c.resources.limits))
message: "every container must declare resource limits"
---
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding # bind the policy to namespaces, like a RoleBinding
metadata: { name: require-limits-binding }
spec:
policyName: require-limits
validationActions: ["Deny"] # or Warn / Audit
matchResources:
namespaceSelector:
matchLabels: { enforce-limits: "true" }

The policy/binding split mirrors RBAC’s Role/RoleBinding: the policy says what’s true, the binding says where it applies and whether a violation denies, warns, or just audits. CEL evaluation is fast, safe (it’s sandboxed and provably terminating — no infinite loops), and removes a whole server from your control plane. It’s now the recommended path for validation logic the apiserver can express; reach for a webhook when you need mutation, external lookups, or logic too rich for CEL.

Hand-writing one webhook (or one VAP) per rule doesn’t scale to an organization’s worth of policy. Two projects turn admission control into a policy platform where rules are themselves declarative objects:

  • OPA / Gatekeeper — a single validating (and mutating) webhook backed by the Open Policy Agent. You write policy in Rego as a ConstraintTemplate, then instantiate Constraint objects that apply it. The same Rego engine governs admission here that might govern API gateways or CI elsewhere, so one policy language spans systems. More powerful and more to learn.
  • Kyverno — policy as Kubernetes YAML, no new language. A ClusterPolicy reads like the manifests you already write, with validate, mutate, and generate rules. Lower barrier, Kubernetes-native, and it can generate objects (e.g. create a default NetworkPolicy in every new namespace).
# Kyverno: require a non-root securityContext, in plain YAML
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata: { name: require-run-as-non-root }
spec:
validationFailureAction: Enforce
rules:
- name: check-non-root
match: { any: [{ resources: { kinds: ["Pod"] } }] }
validate:
message: "Pods must set runAsNonRoot: true"
pattern:
spec:
securityContext:
runAsNonRoot: true

Both compose with the broader idea of Compliance as Code: the rules live in git, get reviewed in a diff, and are enforced on every change rather than audited once a quarter. This is the same payoff the reconciliation loop gives for state, applied to policy.

Pod Security Admission: the batteries-included baseline

Section titled “Pod Security Admission: the batteries-included baseline”

There’s one extremely common policy need — don’t let Pods do dangerous host-level things — that’s now built straight into the apiserver as the PodSecurity admission controller, so you needn’t run any engine for it. It enforces the Pod Security Standards, three named profiles of increasing strictness:

ProfileAllowsTypical use
privilegedeverything — no restrictionstrusted system/infra workloads
baselineblocks the worst (host namespaces, privileged containers, most hostPath)general workloads
restrictedbaseline + must run non-root, drop all capabilities, seccomp RuntimeDefaulthardened, least-privilege

You opt a namespace in with labels, and — crucially — each profile has three independent modes:

apiVersion: v1
kind: Namespace
metadata:
name: payments
labels:
pod-security.kubernetes.io/enforce: restricted # reject violating Pods
pod-security.kubernetes.io/audit: restricted # log violations to the audit log
pod-security.kubernetes.io/warn: restricted # warn the user on kubectl apply

The three modes let you roll a policy out safely: turn on warn and audit first to see what would break without breaking it, then flip enforce once the warnings are clean. PSA is deliberately non-mutating — it won’t fix a Pod for you, only judge it — which is why teams often pair it with a mutating policy (Kyverno, a webhook) that adds the secure defaults PSA then verifies.

The recurring question: what manual, error-prone step does this remove — and how does it make production safer? Without admission control, “we don’t run containers as root” or “every workload declares limits” is a convention — enforced by code review if you’re lucky, by a wiki page if you’re not, and violated the first time someone’s in a hurry at 2am. The manual step is a human remembering and checking every manifest, and humans forget. Admission control turns the convention into a gate the cluster itself enforces on every write: a non-compliant object is rejected before it ever reaches etcd, mutating admission stamps in the safe defaults so the easy path is the secure path, and the rule lives in version control as reviewable policy. The cost is real — webhooks add latency and a failure mode to the write path, and a too-strict policy can block legitimate work — which is exactly why CEL/VAP, tight rules, and the warn-before-enforce rollout exist. Next, we go deep on what those policies are usually protecting: Security in Depth.

Step back from how the gate works and answer the five questions that separate adopting it from cargo-culting it:

  • Why does it exist? Because authz answers “may you create a Pod” but RBAC has no vocabulary for what kind — whether it runs as root, mounts hostPath, or omits resource limits. Those are properties of the object, not the verb, and admission is the only stage in the request path that can both mutate and reject on the basis of contents.
  • What problem does it solve? It turns a convention — “we don’t run as root,” “every workload declares limits” — from a wiki page enforced by code review into a gate the cluster enforces on every write, rejecting non-compliant objects before they reach etcd and stamping in safe defaults via mutation so the easy path is the secure one.
  • What are the trade-offs? A webhook adds latency and a failure mode to the write path — failurePolicy: Fail plus a cluster-wide pods rule plus a crashed webhook halts all Pod creation, including the webhook’s own replacements. In-process CEL/ValidatingAdmissionPolicy removes the network hop and that outage, but can’t mutate or do external lookups.
  • When should I avoid it? Reach for CEL/VAP rather than a webhook when the rule is a pure check; use the built-in PSA for the fixed Pod-level dangers instead of running a whole policy engine; and never flip a strict policy straight to enforce — roll out warn+audit first so it can’t mass-reject legitimate work.
  • What breaks if I remove it? “No root,” “limits required,” “images only from our registry” revert to conventions a hurried human forgets at 2am; mutating defaults stop being stamped in; and the rule no longer lives as reviewable policy in git — exactly the lesson PSP taught when its non-deterministic, RBAC-fused model was removed in 1.25.
  1. Why does the apiserver run all mutating webhooks before any validating webhook, rather than interleaving them? What property does that ordering guarantee?
  2. A validating webhook is registered with failurePolicy: Fail and a rule matching all Pods cluster-wide. The webhook’s Deployment crashes. What happens, and why can it become unrecoverable?
  3. When would you reach for a ValidatingAdmissionPolicy (CEL) instead of a validating webhook — and when does the webhook win? What can a webhook do that a VAP cannot?
  4. Pod Security Admission offers enforce, audit, and warn modes per profile. How do you use all three to roll out the restricted profile to a busy namespace without an outage?
  5. Using the book’s thread, explain how admission control turns a security convention into a guarantee, and name one concrete cost it introduces.
Show answers
  1. Because mutation must finish before validation begins — a validator must judge the final object that will actually be persisted. If they interleaved, a mutator could change an object after a validator approved it, so the stored object might violate a policy that “passed.” Running all mutators first (re-running if any mutates) then all validators guarantees validation sees the settled result, and makes validation order-independent (the answer is just the AND of every verdict).
  2. With failurePolicy: Fail, an unreachable webhook causes the apiserver to reject every matching request — so no Pods can be created cluster-wide. It can become unrecoverable because the webhook’s own replacement Pods are also Pods: they’re rejected too, so the webhook can never come back. The fixes are to exclude kube-system/the webhook’s namespace via namespaceSelector and keep the webhook more available than what it guards.
  3. Use a VAP/CEL when the rule is a pure check the apiserver can express: it runs in-process (no network hop, no server to run, no failurePolicy outage), is sandboxed and fast. Use a webhook when you need to mutate the object, do external lookups (e.g. query a registry), or run logic too rich for CEL. A VAP can only validate; a webhook can also mutate and call out.
  4. Turn on warn and audit for restricted first — these surface every violation (to the user on apply, and to the audit log) without rejecting anything. Fix or exempt the flagged workloads while they keep running, and once the warnings are clean, flip enforce to restricted. This converts a potential mass rejection into a visible, fixable backlog.
  5. A convention (“we don’t run as root”) relies on a human remembering and checking every manifest, and humans forget under pressure. Admission control makes the cluster reject non-compliant objects before they reach etcd and stamp in safe defaults via mutation — the rule is enforced on every write and lives as reviewable policy in git. The cost: webhooks add latency and a failure mode to the write path (and an over-strict policy can block legitimate work).