Back to Blog

Kubernetes Admission Control for the CKS Exam: ValidatingAdmissionPolicy, Webhooks, OPA Gatekeeper & Kyverno

A hands-on guide to admission control for the CKS exam — how the admission chain works, enabling built-in plugins, writing ValidatingAdmissionPolicy with CEL, configuring ImagePolicyWebhook, and enforcing policy with OPA Gatekeeper and Kyverno, with copy-paste YAML and kubectl examples.

By Sailor Team , July 16, 2026

Admission control is the single highest-leverage concept on the Certified Kubernetes Security Specialist (CKS) exam, and it’s also the most under-studied. It doesn’t own a domain of its own — instead it shows up inside Cluster Setup, Cluster Hardening, Minimize Microservice Vulnerabilities, and Supply Chain Security. That’s four of six domains. If a CKS task says “ensure that no pod can ever…”, the answer is almost always admission control.

The reason is structural. RBAC answers “is this user allowed to make this request?” Admission control answers a very different question: “regardless of who is asking, is this object acceptable?” A cluster-admin with full RBAC rights can still be stopped from creating a privileged pod by an admission policy. That’s the gap admission control fills, and it’s why every serious Kubernetes hardening story runs through it.

This guide walks the admission chain end to end from a practitioner’s perspective: how requests flow, which built-in plugins matter, how to write modern ValidatingAdmissionPolicy rules in CEL, how to wire up ImagePolicyWebhook (a classic exam task), and how OPA Gatekeeper and Kyverno fit. Everything here is runnable on a kubeadm cluster.

Where Admission Control Sits in the Request Lifecycle

Every request to the kube-apiserver walks the same path. Knowing the order is worth real points, because exam questions frequently hinge on which stage rejected a request.

kubectl / client


1. Authentication      → Who are you?            (certs, tokens, OIDC)


2. Authorization       → Are you allowed?        (RBAC, Node, Webhook)


3. Mutating admission  → Change the object       (webhooks, plugins)


4. Object schema validation → Is the YAML valid?


5. Validating admission → Accept or reject       (webhooks, policies)


6. Persist to etcd

Four things follow from this diagram, and each is a recurring exam cue:

  • Mutation always runs before validation. A mutating webhook that injects a sidecar runs first; a validating policy then sees the final object, sidecar included. You cannot validate your way around something a later mutator adds — there is no later mutator.
  • Admission runs after authorization. Admission is not a substitute for RBAC; it’s a second, orthogonal gate. Questions describing “even administrators must not be able to…” are pointing at admission, because RBAC alone can’t constrain someone who holds the permission.
  • Admission only sees writes. Create, update, delete, and connect are intercepted. A read never passes through admission control, so admission cannot filter what a user can see.
  • Admission is not retroactive. Enabling a policy today does not touch the workloads already running in etcd. This is the classic trap: candidates enforce a policy, see existing violating pods still running, and assume the policy failed. It didn’t — admission only gates new writes. Existing pods must be recreated (or audited) separately.

Built-in Admission Plugins

The API server ships with a set of compiled-in admission plugins, toggled with --enable-admission-plugins and --disable-admission-plugins in the kube-apiserver manifest (/etc/kubernetes/manifests/kube-apiserver.yaml on a kubeadm cluster). Many are on by default; the CKS-relevant ones are worth knowing by name.

PluginWhat it doesWhy CKS cares
NodeRestrictionLimits what a kubelet can modify — it can only change its own Node object and pods bound to itBlocks a compromised node from tampering with other nodes; on by default in kubeadm
PodSecurityEnforces the Pod Security Standards (privileged / baseline / restricted) per namespaceThe built-in replacement for PodSecurityPolicy
AlwaysPullImagesForces imagePullPolicy: Always on every podStops a tenant from reusing a cached image they no longer have registry credentials for
ImagePolicyWebhookCalls an external service to approve or deny imagesRegistry allow-listing; a frequent hands-on task
ValidatingAdmissionWebhookDispatches to your validating webhooksRequired for Gatekeeper/Kyverno validation
MutatingAdmissionWebhookDispatches to your mutating webhooksRequired for sidecar injection, defaulting
ResourceQuota / LimitRangerEnforces quotas and default resource limitsDoS resistance — an unbounded pod is an availability risk
ServiceAccountAutomates service account token handlingUnderpins pod identity

To enable a plugin, edit the static pod manifest on the control-plane node:

sudo vim /etc/kubernetes/manifests/kube-apiserver.yaml
spec:
  containers:
  - command:
    - kube-apiserver
    - --enable-admission-plugins=NodeRestriction,PodSecurity,AlwaysPullImages

The kubelet watches that directory and restarts the API server automatically when the file changes. Give it 30–60 seconds, then verify:

# Watch the API server come back
kubectl -n kube-system get pods -l component=kube-apiserver -w

# Confirm the flag took effect
ps -ef | grep kube-apiserver | tr ' ' '\n' | grep enable-admission

A hard-won exam tip: back the manifest up outside /etc/kubernetes/manifests before editing it. If you drop a broken file in that directory, the kubelet tries to start it, the API server dies, and kubectl stops answering — which makes recovery a lot more stressful under time pressure. sudo cp /etc/kubernetes/manifests/kube-apiserver.yaml /root/kube-apiserver.yaml.bak costs three seconds. Also note that copying a backup into that directory is itself a mistake: the kubelet will try to run it as a second static pod.

For the deeper API-server and kubelet hardening story around these flags, see the cluster setup and hardening guide.

PodSecurity Admission: The Fast Win

The PodSecurity plugin enforces the Pod Security Standards and is configured entirely with namespace labels — no CRDs, no controllers, no webhooks. It’s the cheapest meaningful hardening available, and it’s a common opener on the exam.

Three levels, three modes:

LevelMeaning
privilegedUnrestricted; the default when unlabeled
baselineBlocks known privilege escalations (hostNetwork, hostPID, privileged containers)
restrictedEnforces hardening best practice (non-root, seccomp, dropped capabilities)
ModeEffect
enforceReject violating pods
auditAllow, but record a violation in the audit log
warnAllow, but return a warning to the client
# Enforce 'restricted' on a namespace, and warn/audit at the same level
kubectl label namespace production \
  pod-security.kubernetes.io/enforce=restricted \
  pod-security.kubernetes.io/warn=restricted \
  pod-security.kubernetes.io/audit=restricted

Verify enforcement by trying something that should fail:

kubectl -n production run bad --image=nginx \
  --overrides='{"spec":{"containers":[{"name":"bad","image":"nginx","securityContext":{"privileged":true}}]}}'
# Error from server (Forbidden): pods "bad" is forbidden: violates PodSecurity "restricted:latest"

The pragmatic rollout pattern — and a good answer to any “how would you introduce this safely?” question — is to set warn and audit first, watch what breaks, fix the workloads, and only then turn on enforce. Pod Security Standards are covered in more depth in the minimize microservice vulnerabilities guide.

ValidatingAdmissionPolicy: In-Process Policy with CEL

ValidatingAdmissionPolicy reached GA in Kubernetes 1.30 and is the modern, built-in way to enforce custom policy without running a webhook server. Policies are written in CEL (Common Expression Language) and evaluated inside the API server itself.

That “inside the API server” part is the whole point. A validating webhook is a network call to a pod: it adds latency, it needs a TLS certificate, and if it goes down your failurePolicy decides whether the cluster fails open (insecure) or fails closed (outage). ValidatingAdmissionPolicy has none of those failure modes because there is no network hop and no server to keep alive.

The model has two objects:

  • ValidatingAdmissionPolicywhat the rule is.
  • ValidatingAdmissionPolicyBindingwhere it applies and how strictly.

Separating them is what makes a policy reusable: one policy, many bindings, each scoped to different namespaces at different enforcement levels.

Here’s a policy that denies privileged containers:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: deny-privileged-containers
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["pods"]
  validations:
  - expression: >-
      !object.spec.containers.exists(c,
        has(c.securityContext) &&
        has(c.securityContext.privileged) &&
        c.securityContext.privileged == true)
    message: "Privileged containers are not allowed."

And the binding that activates it:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
  name: deny-privileged-containers-binding
spec:
  policyName: deny-privileged-containers
  validationActions: ["Deny"]
  matchResources:
    namespaceSelector:
      matchLabels:
        environment: production

A policy with no binding does nothing. This is the number-one reason a candidate’s ValidatingAdmissionPolicy “doesn’t work” — the policy is written correctly, kubectl get validatingadmissionpolicy shows it, and nothing is enforced, because there’s no binding. Check for both objects.

validationActions accepts Deny, Warn, and Audit, and you can combine them. Exactly like PodSecurity, start with ["Warn", "Audit"], observe, then move to ["Deny"].

A few CEL variables worth memorizing:

VariableMeaning
objectThe incoming object (null on DELETE)
oldObjectThe existing object (null on CREATE)
requestAdmission request metadata (operation, user info)
paramsThe parameter object, if the policy uses paramKind
namespaceObjectThe namespace of the incoming object

A second example — requiring that every image comes from an approved registry:

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
  name: require-approved-registry
spec:
  failurePolicy: Fail
  matchConstraints:
    resourceRules:
    - apiGroups:   [""]
      apiVersions: ["v1"]
      operations:  ["CREATE", "UPDATE"]
      resources:   ["pods"]
  validations:
  - expression: >-
      object.spec.containers.all(c, c.image.startsWith("registry.internal.example.com/"))
    message: "Images must come from registry.internal.example.com."

Note the pairing of CEL macros: exists() to catch any violation (deny if any container is privileged) and all() to require every item to comply (every image from the approved registry). Picking the wrong one inverts your policy, and it will still apply cleanly — it just won’t do what you meant. When a policy behaves backwards, check this first.

Test it the way you’d test any control — by trying to violate it:

kubectl run test-denied --image=docker.io/library/nginx
# admission webhook denied the request: Images must come from registry.internal.example.com.

Also remember matchConstraints targets pods, not Deployments. A Deployment that violates the policy is admitted happily — the Deployment object itself has no spec.containers. The rejection surfaces later, on the ReplicaSet’s pod creation, which means you’ll see it in kubectl describe replicaset rather than as an error from kubectl apply. Candidates lose time hunting for this. If a question wants immediate feedback at apply time, match the workload resources as well.

Admission Webhooks

Webhooks are the older, more flexible mechanism: the API server sends an AdmissionReview to your HTTPS service, and your service replies allow or deny. This is what Gatekeeper and Kyverno register under the hood.

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: pod-policy.example.com
webhooks:
- name: pod-policy.example.com
  clientConfig:
    service:
      name: pod-policy-webhook
      namespace: webhook-system
      path: "/validate"
    caBundle: <base64-encoded-CA-cert>
  rules:
  - apiGroups:   [""]
    apiVersions: ["v1"]
    operations:  ["CREATE", "UPDATE"]
    resources:   ["pods"]
    scope:       "Namespaced"
  admissionReviewVersions: ["v1"]
  sideEffects: None
  failurePolicy: Fail
  timeoutSeconds: 5
  namespaceSelector:
    matchExpressions:
    - key: kubernetes.io/metadata.name
      operator: NotIn
      values: ["kube-system"]

The fields that matter for the exam, and why:

  • failurePolicyFail (deny when the webhook is unreachable) or Ignore (allow). Security questions want Fail: a policy you can bypass by DoS-ing the webhook is not a control. But understand the cost — Fail means a dead webhook stops all matching writes cluster-wide. That is a genuine outage, and it’s why the namespaceSelector exclusion below matters so much.
  • caBundle — the CA that signed the webhook’s serving certificate. Webhooks are HTTPS-only; the API server will not talk plaintext to one.
  • sideEffects — declares whether the webhook mutates external state. None is what you want; it lets the API server safely run dry-run requests.
  • namespaceSelector — exclude kube-system. If your webhook fails closed and gates kube-system, a webhook outage can prevent the control plane’s own components from being rescheduled — and you cannot fix the webhook, because fixing it requires the writes the webhook is blocking. Excluding kube-system is the escape hatch that keeps that from becoming unrecoverable.

Inspect what’s registered on a cluster — a good first move when something is being rejected and you don’t know why:

kubectl get validatingwebhookconfigurations
kubectl get mutatingwebhookconfigurations
kubectl describe validatingwebhookconfiguration pod-policy.example.com

When to reach for which? Prefer ValidatingAdmissionPolicy for validation you can express in CEL — no server, no certs, no outage risk. Reach for a webhook when you need mutation, external lookups (a live CVE database, a signature registry), or logic CEL can’t express.

ImagePolicyWebhook: The Classic Exam Task

ImagePolicyWebhook is a built-in plugin that asks an external backend to approve every image. It appears on the exam because it’s the one admission plugin needing a separate config file, and that indirection is exactly what candidates fumble.

There are three files, and each points at the next. Getting them straight is most of the task.

Step 1 — the admission configuration (/etc/kubernetes/admission/admission-config.yaml):

apiVersion: apiserver.config.k8s.io/v1
kind: AdmissionConfiguration
plugins:
- name: ImagePolicyWebhook
  configuration:
    imagePolicy:
      kubeConfigFile: /etc/kubernetes/admission/image-policy-kubeconfig.yaml
      allowTTL: 50
      denyTTL: 50
      retryBackoff: 500
      defaultAllow: false

defaultAllow: false is the security-relevant setting and the one graders look for. With true, an unreachable backend means every image is admitted — the control silently disappears at exactly the moment you’d want it most.

Step 2 — the kubeconfig pointing at the backend (/etc/kubernetes/admission/image-policy-kubeconfig.yaml):

apiVersion: v1
kind: Config
clusters:
- name: image-policy-backend
  cluster:
    certificate-authority: /etc/kubernetes/admission/ca.crt
    server: https://image-policy.example.com:8443/validate
contexts:
- name: image-policy-context
  context:
    cluster: image-policy-backend
    user: api-server
current-context: image-policy-context
users:
- name: api-server
  user:
    client-certificate: /etc/kubernetes/admission/apiserver-client.crt
    client-key: /etc/kubernetes/admission/apiserver-client.key

Step 3 — wire it into the API server (/etc/kubernetes/manifests/kube-apiserver.yaml):

spec:
  containers:
  - command:
    - kube-apiserver
    - --enable-admission-plugins=NodeRestriction,ImagePolicyWebhook
    - --admission-control-config-file=/etc/kubernetes/admission/admission-config.yaml
    volumeMounts:
    - name: admission-config
      mountPath: /etc/kubernetes/admission
      readOnly: true
  volumes:
  - name: admission-config
    hostPath:
      path: /etc/kubernetes/admission
      type: DirectoryOrCreate

Do not skip the volume mount. The API server runs as a container, so a path that exists on the host is invisible inside it unless mounted. Forgetting this produces an API server that crash-loops with a “no such file or directory” error on a file you can plainly cat from the host — which is a genuinely confusing five minutes if you don’t know to look for it. When the API server won’t come back, read the kubelet’s view of the static pod:

sudo crictl ps -a | grep kube-apiserver
sudo crictl logs <container-id>

OPA Gatekeeper and Kyverno

Both are open-source policy engines that install as webhooks. The CKS doesn’t require you to author complex policies from scratch, but it does expect you to read one, understand what it enforces, and apply it.

OPA Gatekeeper splits policy into two objects: a ConstraintTemplate carrying Rego logic, and a Constraint (a CRD generated from the template) that applies it with parameters.

apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: k8srequiredlabels
spec:
  crd:
    spec:
      names:
        kind: K8sRequiredLabels
      validation:
        openAPIV3Schema:
          type: object
          properties:
            labels:
              type: array
              items:
                type: string
  targets:
  - target: admission.k8s.gatekeeper.sh
    rego: |
      package k8srequiredlabels
      violation[{"msg": msg}] {
        required := input.parameters.labels[_]
        not input.review.object.metadata.labels[required]
        msg := sprintf("Missing required label: %v", [required])
      }
---
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: K8sRequiredLabels
metadata:
  name: require-owner-label
spec:
  enforcementAction: deny
  match:
    kinds:
    - apiGroups: [""]
      kinds: ["Namespace"]
  parameters:
    labels: ["owner"]

The mental model for Rego: you write a violation rule that produces a message when the object is bad. It’s inverted from CEL, where the expression must be true when the object is good. Mixing the two up is easy when you’re switching between them under time pressure.

Kyverno takes a different approach — policies are plain YAML, no new language:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-run-as-non-root
spec:
  validationFailureAction: Enforce
  background: true
  rules:
  - name: check-run-as-non-root
    match:
      any:
      - resources:
          kinds: ["Pod"]
    validate:
      message: "Containers must run as non-root."
      pattern:
        spec:
          containers:
          - securityContext:
              runAsNonRoot: true

Kyverno’s validationFailureAction (Enforce or Audit) is the analogue of Gatekeeper’s enforcementAction and ValidatingAdmissionPolicy’s validationActions. Every engine gives you the same audit-then-enforce ramp, under a different field name. Kyverno also does mutate and generate — auto-creating a default NetworkPolicy in every new namespace, for example — which is genuinely useful and something Gatekeeper’s validation-only model doesn’t cover.

Quick comparison:

ValidatingAdmissionPolicyOPA GatekeeperKyverno
LanguageCELRegoYAML
Runs inAPI serverWebhook podWebhook pod
Extra componentsNoneController + CRDsController + CRDs
MutationNo (separate alpha/beta feature)NoYes
GenerationNoNoYes
Failure riskNone (in-process)Webhook downtimeWebhook downtime

Debugging Admission Problems

When a resource is unexpectedly rejected, work the chain in order rather than guessing:

# 1. What's actually registered?
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations
kubectl get validatingadmissionpolicy,validatingadmissionpolicybinding

# 2. Read the rejection message — it names the policy or webhook
kubectl apply -f pod.yaml
# Error from server (Forbidden): admission webhook "pod-policy.example.com" denied the request: ...

# 3. Is the webhook backend even alive?
kubectl -n webhook-system get pods
kubectl -n webhook-system logs deploy/pod-policy-webhook

# 4. Dry-run to test without persisting
kubectl apply -f pod.yaml --dry-run=server

--dry-run=server is the underrated one: it runs the full admission chain without writing to etcd. That makes it the fastest way to check whether a policy behaves the way you intended, and it’s safe to run against production.

A rejection message naming a webhook points at Gatekeeper, Kyverno, or a custom service. A message naming a policy points at ValidatingAdmissionPolicy. A message mentioning PodSecurity points at the built-in plugin and a namespace label. Reading the message carefully tells you which of the three mechanisms is talking — and saves you from debugging the wrong layer.

Practice This on a Real Cluster

Admission control is one of those topics that reads simple and breaks in practice. The concepts fit on a page; the exam tests whether you can edit a static pod manifest without bricking the API server, notice that a policy needs a binding, and recover when the kubelet won’t restart a container — all against a clock.

Sailor.sh’s Certified Kubernetes Security Specialist (CKS) Mock Exam Bundle gives you five full-length, browser-based performance exams against live clusters, with the same time pressure and the same style of tasks: enabling admission plugins, wiring ImagePolicyWebhook config files, and writing policies that actually enforce. Breaking and repairing a real API server in practice is what makes exam day uneventful.

If you’d rather start with a free local setup, the CKS practice environment guide walks through building one, and the CKS study plan sequences the domains. For the wider Kubernetes security picture, the Kubernetes security best practices guide is a good companion, and the official Kubernetes admission control documentation is allowed during the exam — know how to navigate it quickly.

Frequently Asked Questions

Is admission control its own CKS domain?

No — and that’s exactly why it’s easy to under-prepare. It’s woven through Cluster Setup, Cluster Hardening, Minimize Microservice Vulnerabilities, and Supply Chain Security. Treat it as a cross-cutting skill rather than a chapter to check off.

Should I learn ValidatingAdmissionPolicy or webhooks?

Both. ValidatingAdmissionPolicy is the modern default and needs no extra components, so reach for it when the rule is expressible in CEL. But webhooks underpin Gatekeeper and Kyverno, and mutation still requires one — you’ll see both on a real cluster.

Do I need to write Rego from scratch for the exam?

You should be able to read a ConstraintTemplate and say what it enforces, and to apply a provided policy correctly. Authoring non-trivial Rego under exam time pressure is unlikely to be required.

Why isn’t my ValidatingAdmissionPolicy doing anything?

Almost always a missing ValidatingAdmissionPolicyBinding — a policy without one is inert. If the binding exists, check validationActions is ["Deny"] rather than only Warn/Audit, and confirm your matchConstraints targets the resource you’re actually creating (pods, not Deployments).

What happens if my admission webhook goes down?

It depends on failurePolicy. With Fail, all matching requests are rejected — secure, but potentially a cluster-wide outage. With Ignore, requests sail through unchecked — available, but your control is gone. Security questions want Fail, paired with a namespaceSelector that excludes kube-system so a webhook outage stays recoverable.

Does admission control affect pods that are already running?

No. Admission gates writes to the API server, so it only sees new create and update requests. Existing pods keep running until something recreates them. To find current violations, use audit mode or Gatekeeper’s audit controller, and plan a rollout.

What’s the difference between admission control and RBAC?

RBAC decides whether you may make a request; admission decides whether the object itself is acceptable. They run at different stages and neither substitutes for the other. RBAC can’t stop a legitimately-authorized admin from creating a privileged pod — admission control can.

Conclusion

Admission control is where Kubernetes security stops being advisory and starts being enforced. RBAC decides who can ask; admission decides what the cluster will actually accept — and that distinction is the backbone of a large share of CKS tasks.

If you’re building a study plan around this, prioritize in this order. PodSecurity admission first: it’s namespace labels, it’s five minutes, and it eliminates whole classes of privilege escalation. ValidatingAdmissionPolicy next: it’s the modern built-in path, it has no failure modes, and its GA status makes it increasingly the expected answer. ImagePolicyWebhook third: the three-file setup is fiddly, but it’s a well-known exam task and entirely learnable. Gatekeeper and Kyverno last: know what they are and how to read their policies, without sinking time into authoring complex Rego.

Underneath all of it sits one habit worth more than any single mechanism: audit before you enforce. Every engine here gives you a warn-or-audit mode, and using it is the difference between a policy that hardens a cluster and a policy that takes it down. Learn the mechanisms, but learn the rollout discipline with them — that’s what separates a candidate who passes from a practitioner who ships.

Limited Time Offer: Get 80% off all Mock Exam Bundles | Sale ends in 7 days. Start learning today.

Claim Now