Kubernetes

Write a Kubernetes Admission Webhook From Scratch

Advanced24 min to complete12 min readJuly 29, 2026Updated August 26, 2026

Quick answer

Hand-write the AdmissionReview request/response cycle in Go — a validating webhook that rejects Pods without memory limits, and a mutating webhook that stamps an admission annotation via JSON Patch — then wire up cert-manager for TLS and deploy both in-cluster.

advanced · 24 min

Before you begin

  • Go installed (1.21+)
  • A cluster you can use (kind or minikube is fine) and kubectl configured
  • cert-manager installed in the cluster (or willingness to install it — one `kubectl apply` away)
  • Docker, for the build/deploy step
  • Comfort reading Go and YAML
Kubernetes
Admission Webhooks
Go
Security
Platform Engineering
TLS
cert-manager

Policy engines like Kyverno and OPA Gatekeeper are themselves admission webhooks — they just hide the AdmissionReview plumbing behind a YAML policy language. This tutorial skips the abstraction and builds the raw mechanism directly: a Go HTTP server that decodes AdmissionReview requests, inspects the object inside them, and returns an Allowed decision or a JSON patch. No CRD, no controller-runtime, no reconcile loop — just the request/response contract the API server itself speaks to every webhook.

By the end you'll have two webhooks running in-cluster behind real TLS: a validating one that rejects Pods missing resources.limits.memory, and a mutating one that stamps an admitted-by annotation onto every Pod it sees. You'll also understand the one config knob — failurePolicy — that decides whether a bug in your code merely misses a Pod or blocks every Pod creation in the cluster.

What You'll Build

  • A Go HTTP server implementing the admission.k8s.io/v1 request/response cycle by hand
  • A ValidatingWebhookConfiguration that rejects Pods without a memory limit
  • A MutatingWebhookConfiguration that injects an admitted-by annotation when it's missing
  • A self-signed Issuer + Certificate from cert-manager, injected into both configs via the CA injector annotation
  • A Deployment + Service running the webhook server in-cluster, with failurePolicy and namespaceSelector set deliberately, not left at their defaults

Step 1: Scaffold the Project

bash
mkdir admission-webhook && cd admission-webhook
go mod init admission-webhook
go get k8s.io/[email protected] k8s.io/[email protected]

Confirm you have a cluster and that cert-manager is installed:

bash
1kubectl cluster-info
2# if you don't have one: kind create cluster --name webhook-demo
3
4kubectl get pods -n cert-manager
5# if that errors, install it:
6kubectl apply -f https://github.com/cert-manager/cert-manager/releases/latest/download/cert-manager.yaml
7kubectl -n cert-manager rollout status deployment/cert-manager-webhook

Create the namespace everything else in this tutorial lives in:

bash
kubectl create namespace webhook-demo

Step 2: Decode and Respond to an AdmissionReview

The API server sends a JSON-encoded AdmissionReview with a Request populated; your job is to return the same envelope with a Response populated instead. Write a small helper both handlers will share:

go
1// main.go
2package main
3
4import (
5	"encoding/json"
6	"errors"
7	"io"
8	"log"
9	"net/http"
10
11	admissionv1 "k8s.io/api/admission/v1"
12	corev1 "k8s.io/api/core/v1"
13)
14
15var errNoRequest = errors.New("body is not an AdmissionReview with a populated request")
16
17func readAdmissionRequest(r *http.Request) (*admissionv1.AdmissionReview, *corev1.Pod, error) {
18	body, err := io.ReadAll(r.Body)
19	if err != nil {
20		return nil, nil, err
21	}
22
23	var review admissionv1.AdmissionReview
24	if err := json.Unmarshal(body, &review); err != nil {
25		return nil, nil, err
26	}
27
28	// Anything that isn't the API server — a probe, a curl, a scanner — can
29	// unmarshal cleanly into an empty AdmissionReview. Without this check the
30	// next line panics on a nil Request.
31	if review.Request == nil {
32		return nil, nil, errNoRequest
33	}
34
35	var pod corev1.Pod
36	if err := json.Unmarshal(review.Request.Object.Raw, &pod); err != nil {
37		return nil, nil, err
38	}
39	return &review, &pod, nil
40}
41
42func writeAdmissionResponse(w http.ResponseWriter, review *admissionv1.AdmissionReview, resp *admissionv1.AdmissionResponse) {
43	resp.UID = review.Request.UID
44	review.Response = resp
45	review.Request = nil
46
47	w.Header().Set("Content-Type", "application/json")
48	if err := json.NewEncoder(w).Encode(review); err != nil {
49		log.Printf("failed to encode response: %v", err)
50	}
51}

The nil guard is not defensive padding. json.Unmarshal happily decodes {}, a health probe, or a stray curl into an AdmissionReview with a nil Request, and dereferencing it panics. net/http recovers the panic per-connection so the process survives, but the caller gets a dropped connection rather than a response — and under failurePolicy: Fail on a cluster-wide pods rule, the API server reads that as a rejection. A malformed request should be a 400, which is what returning an error here produces.

Two more things worth internalizing before you write the handlers: review.Request.Object.Raw is the raw JSON of the object being admitted (a Pod, here, because that's all our webhook rules will match on), and resp.UID must echo review.Request.UID back exactly — the API server matches responses to requests by that field, and a mismatched or missing UID gets the whole response discarded.

Step 3: The Validating Handler — Require Memory Limits

A Pod with no memory limit can consume the entire node and get every other Pod on it OOM-killed by the node's memory-pressure eviction, not by a controlled OOMKilled on itself. Rejecting Pods that skip resources.limits.memory is a genuinely useful cluster-wide policy, not a toy example:

go
1// validate.go
2package main
3
4import (
5	"fmt"
6	"net/http"
7
8	admissionv1 "k8s.io/api/admission/v1"
9	corev1 "k8s.io/api/core/v1"
10	metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
11)
12
13func handleValidate(w http.ResponseWriter, r *http.Request) {
14	review, pod, err := readAdmissionRequest(r)
15	if err != nil {
16		http.Error(w, err.Error(), http.StatusBadRequest)
17		return
18	}
19
20	for _, c := range pod.Spec.Containers {
21		if _, ok := c.Resources.Limits[corev1.ResourceMemory]; !ok {
22			writeAdmissionResponse(w, review, &admissionv1.AdmissionResponse{
23				Allowed: false,
24				Result: &metav1.Status{
25					Message: fmt.Sprintf(
26						"container %q has no resources.limits.memory set — this cluster requires a memory limit on every container",
27						c.Name,
28					),
29				},
30			})
31			return
32		}
33	}
34
35	writeAdmissionResponse(w, review, &admissionv1.AdmissionResponse{Allowed: true})
36}

Result.Message is what shows up in the caller's kubectl apply output verbatim, so write it for the human who just got rejected, not for a log file.

Step 4: The Mutating Handler — Stamp an Admission Annotation

Mutating webhooks don't rewrite the object directly — they return a JSON Patch (RFC 6902) describing the edit, and the API server applies it. Here, stamp an annotation recording that this policy admitted the Pod — a provenance marker you can later select on to find Pods that predate the webhook.

Pick a field the API server hasn't already filled in. The obvious first mutating webhook — "default imagePullPolicy when the user didn't set one" — cannot work, and it fails in the most misleading way possible. Defaulting happens when the API server decodes the request body, which is before any admission plugin or webhook runs. By the time your handler sees the Pod, imagePullPolicy is always populated (Always for a :latest tag, IfNotPresent otherwise), so an if c.ImagePullPolicy == "" guard never fires. Worse, checking the created Pod shows IfNotPresent exactly as you hoped — because that is the API server's own default, with or without your webhook installed. You would ship a no-op and have a passing test that proves nothing. The same trap applies to terminationGracePeriodSeconds, restartPolicy, dnsPolicy, and every other field with a default in the Pod schema.

go
1// mutate.go
2package main
3
4import (
5	"encoding/json"
6	"net/http"
7	"strings"
8
9	admissionv1 "k8s.io/api/admission/v1"
10)
11
12type patchOp struct {
13	Op    string `json:"op"`
14	Path  string `json:"path"`
15	Value any    `json:"value"`
16}
17
18const admittedByKey = "webhook-demo.example.com/admitted-by"
19
20// JSON Pointer escaping (RFC 6901): "~" becomes "~0", "/" becomes "~1".
21var pointerEscaper = strings.NewReplacer("~", "~0", "/", "~1")
22
23func handleMutate(w http.ResponseWriter, r *http.Request) {
24	review, pod, err := readAdmissionRequest(r)
25	if err != nil {
26		http.Error(w, err.Error(), http.StatusBadRequest)
27		return
28	}
29
30	var patches []patchOp
31	if _, stamped := pod.Annotations[admittedByKey]; !stamped {
32		if pod.Annotations == nil {
33			// "add" fails if the parent object is absent, so create the
34			// whole map in one op rather than adding a key to nothing.
35			patches = append(patches, patchOp{
36				Op:    "add",
37				Path:  "/metadata/annotations",
38				Value: map[string]string{admittedByKey: "require-memory-limits"},
39			})
40		} else {
41			patches = append(patches, patchOp{
42				Op:    "add",
43				Path:  "/metadata/annotations/" + pointerEscaper.Replace(admittedByKey),
44				Value: "require-memory-limits",
45			})
46		}
47	}
48
49	resp := &admissionv1.AdmissionResponse{Allowed: true}
50	if len(patches) > 0 {
51		patchBytes, err := json.Marshal(patches)
52		if err != nil {
53			http.Error(w, err.Error(), http.StatusInternalServerError)
54			return
55		}
56		patchType := admissionv1.PatchTypeJSONPatch
57		resp.Patch = patchBytes
58		resp.PatchType = &patchType
59	}
60
61	writeAdmissionResponse(w, review, resp)
62}

Two JSON Patch details that bite in production. First, paths are positional — to patch a container you would write /spec/containers/0/..., because JSON Patch has no concept of "the container named X"; it addresses the object by index into the array the API server sent you, which is exactly the array in review.Request.Object.Raw. Second, add requires the parent to exist: add /metadata/annotations/foo against a Pod with no annotations at all is an error, not an implicit create, which is why the nil-map branch above adds the entire object instead. Annotation and label keys also need RFC 6901 escaping, since a key like webhook-demo.example.com/admitted-by contains a / that would otherwise read as a path separator.

Step 5: Wire Up main() and Serve Over TLS

The API server refuses to call a webhook over plain HTTP — this isn't configurable. Point ListenAndServeTLS at a cert and key you'll mount from a Secret in Step 8:

go
1// main.go (continued)
2func main() {
3	mux := http.NewServeMux()
4	mux.HandleFunc("/validate", handleValidate)
5	mux.HandleFunc("/mutate", handleMutate)
6
7	server := &http.Server{Addr: ":8443", Handler: mux}
8	log.Println("admission webhook listening on :8443")
9	log.Fatal(server.ListenAndServeTLS("/certs/tls.crt", "/certs/tls.key"))
10}

Step 6: Build the Image

dockerfile
1# Dockerfile
2FROM golang:1.22 AS build
3WORKDIR /src
4COPY go.mod go.sum ./
5RUN go mod download
6COPY . .
7RUN CGO_ENABLED=0 go build -o /webhook-server .
8
9FROM gcr.io/distroless/static-debian12
10COPY --from=build /webhook-server /webhook-server
11USER 1000
12ENTRYPOINT ["/webhook-server"]
bash
docker build -t webhook-server:v0.1.0 .
# push it wherever your cluster can pull from, e.g.:
# docker tag webhook-server:v0.1.0 ghcr.io/your-org/webhook-server:v0.1.0
# docker push ghcr.io/your-org/webhook-server:v0.1.0

If you're using kind, load the image directly instead of pushing anywhere: kind load docker-image webhook-server:v0.1.0 --name webhook-demo.

Step 7: Issue a Serving Certificate With cert-manager

The webhook's Service needs a certificate whose SAN matches its in-cluster DNS name. A self-signed Issuer scoped to this one Certificate is standard practice for a webhook serving cert — you're not issuing certs the outside world needs to trust, only ones the API server will:

yaml
1# certificate.yaml
2apiVersion: cert-manager.io/v1
3kind: Issuer
4metadata:
5  name: webhook-selfsigned-issuer
6  namespace: webhook-demo
7spec:
8  selfSigned: {}
9---
10apiVersion: cert-manager.io/v1
11kind: Certificate
12metadata:
13  name: webhook-server-cert
14  namespace: webhook-demo
15spec:
16  secretName: webhook-server-tls
17  dnsNames:
18    - webhook-server.webhook-demo.svc
19    - webhook-server.webhook-demo.svc.cluster.local
20  issuerRef:
21    name: webhook-selfsigned-issuer
22    kind: Issuer
bash
kubectl apply -f certificate.yaml
kubectl -n webhook-demo wait --for=condition=Ready certificate/webhook-server-cert --timeout=60s

That produces a Secret named webhook-server-tls containing tls.crt, tls.key, and ca.crt — the last of which is what the API server needs to trust the first two, and is what you'll inject into the webhook configs in Step 9.

Step 8: Deploy the Server In-Cluster

yaml
1# deploy.yaml
2apiVersion: apps/v1
3kind: Deployment
4metadata:
5  name: webhook-server
6  namespace: webhook-demo
7spec:
8  replicas: 2
9  selector:
10    matchLabels: { app: webhook-server }
11  template:
12    metadata:
13      labels: { app: webhook-server }
14    spec:
15      containers:
16        - name: webhook-server
17          image: webhook-server:v0.1.0
18          ports:
19            - containerPort: 8443
20          volumeMounts:
21            - name: tls
22              mountPath: /certs
23              readOnly: true
24          securityContext:
25            runAsNonRoot: true
26            readOnlyRootFilesystem: true
27            allowPrivilegeEscalation: false
28            capabilities:
29              drop: ["ALL"]
30          resources:
31            requests: { cpu: 100m, memory: 64Mi }
32            limits: { memory: 128Mi }
33      volumes:
34        - name: tls
35          secret:
36            secretName: webhook-server-tls
37---
38apiVersion: v1
39kind: Service
40metadata:
41  name: webhook-server
42  namespace: webhook-demo
43spec:
44  selector: { app: webhook-server }
45  ports:
46    - port: 443
47      targetPort: 8443
bash
kubectl apply -f deploy.yaml
kubectl -n webhook-demo rollout status deployment/webhook-server

Two replicas, deliberately: this Service is about to be in the path of every Pod creation cluster-wide, so a single-replica webhook is a self-inflicted single point of failure.

Step 9: Register the Webhooks and Inject the CA

Rather than manually copying ca.crt out of the Secret with kubectl get secret ... -o jsonpath and pasting it into caBundle, use cert-manager's CA injector: annotate the WebhookConfiguration with cert-manager.io/inject-ca-from, pointing at the Certificate from Step 7, and a controller running as part of cert-manager keeps caBundle populated and rotated automatically:

yaml
1# webhooks.yaml
2apiVersion: admissionregistration.k8s.io/v1
3kind: ValidatingWebhookConfiguration
4metadata:
5  name: require-memory-limits
6  annotations:
7    cert-manager.io/inject-ca-from: webhook-demo/webhook-server-cert
8webhooks:
9  - name: require-memory-limits.webhook-demo.svc
10    clientConfig:
11      service:
12        name: webhook-server
13        namespace: webhook-demo
14        path: /validate
15        port: 443
16    rules:
17      - apiGroups: [""]
18        apiVersions: ["v1"]
19        operations: ["CREATE"]
20        resources: ["pods"]
21        scope: "Namespaced"
22    admissionReviewVersions: ["v1"]
23    sideEffects: None
24    failurePolicy: Fail
25    namespaceSelector:
26      matchExpressions:
27        - key: kubernetes.io/metadata.name
28          operator: NotIn
29          values: ["kube-system", "webhook-demo"]
30---
31apiVersion: admissionregistration.k8s.io/v1
32kind: MutatingWebhookConfiguration
33metadata:
34  name: stamp-admitted-by
35  annotations:
36    cert-manager.io/inject-ca-from: webhook-demo/webhook-server-cert
37webhooks:
38  - name: stamp-admitted-by.webhook-demo.svc
39    clientConfig:
40      service:
41        name: webhook-server
42        namespace: webhook-demo
43        path: /mutate
44        port: 443
45    rules:
46      - apiGroups: [""]
47        apiVersions: ["v1"]
48        operations: ["CREATE"]
49        resources: ["pods"]
50        scope: "Namespaced"
51    admissionReviewVersions: ["v1"]
52    sideEffects: None
53    failurePolicy: Ignore
54    namespaceSelector:
55      matchExpressions:
56        - key: kubernetes.io/metadata.name
57          operator: NotIn
58          values: ["kube-system", "webhook-demo"]
bash
kubectl apply -f webhooks.yaml
kubectl get validatingwebhookconfigurations require-memory-limits -o jsonpath='{.webhooks[0].clientConfig.caBundle}' | head -c 40
# should print base64 data, not an empty string

If that last command prints nothing, the CA injector hasn't reconciled yet — give it a few seconds and retry before assuming the webhook is broken.

Step 10: failurePolicy: Fail vs Ignore — Choose Deliberately

Notice the two configs above disagree on purpose. failurePolicy: Fail means: if the webhook server is unreachable, times out, or errors, the API server treats that as a rejection — no Pod gets created until the webhook answers. failurePolicy: Ignore means the opposite: if the webhook can't be reached, the API server proceeds as if it had said "allowed."

For the validating webhook, Fail is correct — the entire point is that Pods without a memory limit must never slip through, even during an outage of your own server. For the mutating webhook, Ignore is the safer default — worst case, a Pod is created without the annotation stamp, which is a cosmetic miss, not a policy violation. That asymmetry is the general rule: a webhook that enforces an invariant should fail closed, and one that merely decorates an object should fail open.

The cost of Fail is real: a crashed webhook Deployment, a bad rollout, a networking blip between the API server and the webhook Service — any of it now blocks every Pod creation in every namespace the webhook applies to, cluster-wide, including the Deployment you'd use to fix the webhook itself. That's what namespaceSelector is for above: excluding kube-system means a broken webhook can't stop core cluster components (CoreDNS, CNI pods, the kube-proxy DaemonSet) from scheduling, which is usually the difference between "some app pods are stuck" and "the cluster can no longer self-heal." Excluding the webhook's own namespace (webhook-demo) matters for the same reason — you need to be able to redeploy the fix without the broken webhook blocking its own replacement.

Step 11: Verify Both Webhooks

Rejection path — a Pod with no memory limit:

bash
1kubectl create namespace webhook-test
2cat <<'EOF' | kubectl apply -n webhook-test -f -
3apiVersion: v1
4kind: Pod
5metadata:
6  name: no-limits
7spec:
8  containers:
9    - name: app
10      image: nginx:1.27
11EOF
Error from server: error when creating "STDIN": admission webhook "require-memory-limits.webhook-demo.svc" denied the request:
container "app" has no resources.limits.memory set — this cluster requires a memory limit on every container

Allow + mutate path — a Pod with a limit and no annotations of its own:

bash
1cat <<'EOF' | kubectl apply -n webhook-test -f -
2apiVersion: v1
3kind: Pod
4metadata:
5  name: with-limits
6spec:
7  containers:
8    - name: app
9      image: nginx:1.27
10      resources:
11        limits:
12          memory: 256Mi
13EOF
14
15kubectl get pod with-limits -n webhook-test -o jsonpath='{.metadata.annotations}'
16# {"webhook-demo.example.com/admitted-by":"require-memory-limits"}

The Pod was created and carries an annotation you never set — the mutating webhook patched it before the object was persisted to etcd. This check is only meaningful because nothing else in Kubernetes writes that key: delete the MutatingWebhookConfiguration, recreate the Pod, and the annotation map comes back empty. That is the confirmation that your webhook — and not some built-in default — did the work.

Where to Go Next

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.