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.
Where admission sits in the request path
Section titled “Where admission sits in the request path”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 editsTwo 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.
Built-in admission controllers
Section titled “Built-in admission controllers”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:
| Controller | Pass | What it does |
|---|---|---|
NamespaceLifecycle | validating | Rejects objects in a namespace that’s being deleted or doesn’t exist |
LimitRanger | mutating | Applies a namespace’s LimitRange defaults to Pods that omit requests/limits |
ResourceQuota | validating | Rejects a create that would exceed a namespace’s ResourceQuota |
ServiceAccount | mutating | Auto-mounts the default ServiceAccount token into Pods (see Security in Depth) |
DefaultStorageClass | mutating | Stamps the default StorageClass onto a PVC that names none (see Storage Internals) |
PodSecurity | validating | Enforces Pod Security Standards — covered below |
MutatingAdmissionWebhook | mutating | Calls out to your webhooks |
ValidatingAdmissionWebhook | validating | Calls 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.
Admission webhooks: the contract
Section titled “Admission webhooks: the contract”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/v1kind: ValidatingWebhookConfigurationmetadata: name: require-resource-limitswebhooks: - 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 requestThe 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:
failurePolicy—Fail(the default and the safe choice) rejects requests when your webhook is unreachable;Ignoreadmits them.Failmeans a crashed webhook can halt all Pod creation in the cluster. Scope yourrulesandnamespaceSelectortightly so an outage blasts as little as possible.sideEffects— declare whether processing a request changes external state. SettingNonelets the apiserver call you during a dry-run safely; lying here breakskubectl 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/v1kind: ValidatingAdmissionPolicymetadata: name: require-limitsspec: 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/v1kind: ValidatingAdmissionPolicyBinding # bind the policy to namespaces, like a RoleBindingmetadata: { 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.
Policy engines: Gatekeeper and Kyverno
Section titled “Policy engines: Gatekeeper and Kyverno”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 instantiateConstraintobjects 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
ClusterPolicyreads like the manifests you already write, withvalidate,mutate, andgeneraterules. 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 YAMLapiVersion: kyverno.io/v1kind: ClusterPolicymetadata: { 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: trueBoth 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:
| Profile | Allows | Typical use |
|---|---|---|
privileged | everything — no restrictions | trusted system/infra workloads |
baseline | blocks the worst (host namespaces, privileged containers, most hostPath) | general workloads |
restricted | baseline + must run non-root, drop all capabilities, seccomp RuntimeDefault | hardened, least-privilege |
You opt a namespace in with labels, and — crucially — each profile has three independent modes:
apiVersion: v1kind: Namespacemetadata: 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 applyThe 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 thread: what manual step disappears
Section titled “The thread: what manual step disappears”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.
The architect’s lens
Section titled “The architect’s lens”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: Failplus a cluster-widepodsrule 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 outwarn+auditfirst 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.
Check your understanding
Section titled “Check your understanding”- Why does the apiserver run all mutating webhooks before any validating webhook, rather than interleaving them? What property does that ordering guarantee?
- A validating webhook is registered with
failurePolicy: Failand a rule matching all Pods cluster-wide. The webhook’s Deployment crashes. What happens, and why can it become unrecoverable? - 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? - Pod Security Admission offers
enforce,audit, andwarnmodes per profile. How do you use all three to roll out therestrictedprofile to a busy namespace without an outage? - 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
- 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).
- 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 excludekube-system/the webhook’s namespace vianamespaceSelectorand keep the webhook more available than what it guards. - 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
failurePolicyoutage), 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. - Turn on
warnandauditforrestrictedfirst — 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, flipenforcetorestricted. This converts a potential mass rejection into a visible, fixable backlog. - 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).