Blog
February 17, 2021 Marie H.

Writing Kubernetes Admission Webhooks in Go

Writing Kubernetes Admission Webhooks in Go

Photo by <a href="https://unsplash.com/@sebastiaanstam?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">sebastiaan stam</a> on <a href="https://unsplash.com/?utm_source=cloudista&utm_medium=referral" target="_blank" rel="noopener">Unsplash</a>

Writing Kubernetes Admission Webhooks in Go

Policy enforcement in Kubernetes has a reputation for being complicated. Some of that reputation is earned. But once you understand the admission webhook mechanism, a lot of it clicks into place. This is the post I wanted when I was first building one.

What Admission Webhooks Are

When you submit a resource to the Kubernetes API server — a Deployment, a Pod, anything — the request goes through an admission control chain before it gets persisted. Webhooks let you plug into that chain.

There are two kinds:

Mutating admission webhooks run first and can modify the incoming resource. Classic use cases: inject a sidecar container, add default labels, set resource limits if they're missing.

Validating admission webhooks run after mutations and can only approve or reject — no modifications. Classic use cases: enforce that a cost-center label is present, reject images from untrusted registries, block privileged containers.

You can run both on the same server. I usually do.

Updated March 2026: The controller-runtime library's webhook helpers have matured significantly. If you're starting a new webhook today, the sigs.k8s.io/controller-runtime/pkg/webhook package handles the boilerplate (TLS, request decoding, response encoding) much more cleanly than rolling your own. I'd recommend starting there. What follows is the raw approach, which is still useful to understand.

The Webhook Protocol

The API server sends an HTTP POST with a JSON body containing an AdmissionReview object. Your webhook responds with another AdmissionReview containing your verdict (and optional patches for mutating webhooks). That's the entire protocol.

The hard parts are: serving TLS (Kubernetes requires it — no plain HTTP), getting the certificates bootstrapped, and the patch format (JSON Patch for mutations).

The Go Server

Here's the structure I use. I'll build the cost-center label injector as the example.

package main

import (
    "encoding/json"
    "fmt"
    "log"
    "net/http"

    admissionv1 "k8s.io/api/admission/v1"
    corev1 "k8s.io/api/core/v1"
    metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
)

func main() {
    mux := http.NewServeMux()
    mux.HandleFunc("/mutate", mutateHandler)
    mux.HandleFunc("/validate", validateHandler)
    mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
        w.WriteHeader(http.StatusOK)
    })

    server := &http.Server{
        Addr:    ":8443",
        Handler: mux,
    }

    log.Println("Starting webhook server on :8443")
    // TLS cert and key are mounted from a Secret
    log.Fatal(server.ListenAndServeTLS("/certs/tls.crt", "/certs/tls.key"))
}

The mutating handler:

func mutateHandler(w http.ResponseWriter, r *http.Request) {
    review, err := decodeAdmissionReview(r)
    if err != nil {
        http.Error(w, err.Error(), http.StatusBadRequest)
        return
    }

    response := mutate(review.Request)
    response.UID = review.Request.UID

    writeAdmissionResponse(w, admissionv1.AdmissionReview{
        TypeMeta: metav1.TypeMeta{
            APIVersion: "admission.k8s.io/v1",
            Kind:       "AdmissionReview",
        },
        Response: response,
    })
}

func mutate(req *admissionv1.AdmissionRequest) *admissionv1.AdmissionResponse {
    // Decode the incoming object as a generic map so we can inspect labels
    // without needing to know the exact type
    var obj map[string]interface{}
    if err := json.Unmarshal(req.Object.Raw, &obj); err != nil {
        return &admissionv1.AdmissionResponse{
            Allowed: false,
            Result:  &metav1.Status{Message: err.Error()},
        }
    }

    meta, _ := obj["metadata"].(map[string]interface{})
    labels, _ := meta["labels"].(map[string]interface{})

    // If cost-center label already exists, nothing to do
    if _, ok := labels["cost-center"]; ok {
        return &admissionv1.AdmissionResponse{Allowed: true}
    }

    // Inject a default — in practice you'd derive this from namespace annotations
    // or some other source of truth
    patch := []map[string]interface{}{
        {
            "op":    "add",
            "path":  "/metadata/labels/cost-center",
            "value": "unassigned",
        },
    }

    // Handle the case where labels map doesn't exist yet
    if labels == nil {
        patch = []map[string]interface{}{
            {
                "op":    "add",
                "path":  "/metadata/labels",
                "value": map[string]string{"cost-center": "unassigned"},
            },
        }
    }

    patchBytes, _ := json.Marshal(patch)
    patchType := admissionv1.PatchTypeJSONPatch

    return &admissionv1.AdmissionResponse{
        Allowed:   true,
        Patch:     patchBytes,
        PatchType: &patchType,
    }
}

The decode/encode helpers:

func decodeAdmissionReview(r *http.Request) (*admissionv1.AdmissionReview, error) {
    var review admissionv1.AdmissionReview
    if err := json.NewDecoder(r.Body).Decode(&review); err != nil {
        return nil, fmt.Errorf("decoding request body: %w", err)
    }
    if review.Request == nil {
        return nil, fmt.Errorf("empty admission request")
    }
    return &review, nil
}

func writeAdmissionResponse(w http.ResponseWriter, review admissionv1.AdmissionReview) {
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(review)
}

TLS: The Annoying Part

Kubernetes will not call a webhook over plain HTTP. Your server must present a TLS certificate, and that certificate must be trusted by the API server. There are two practical ways to handle this.

Option 1: cert-manager (what I use). Install cert-manager, create a self-signed Issuer and a Certificate resource targeting your webhook service. cert-manager provisions the cert, injects the CA bundle into your webhook configuration, and handles rotation.

apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
  name: webhook-server-cert
  namespace: webhook-system
spec:
  secretName: webhook-server-tls
  dnsNames:
    - webhook-server.webhook-system.svc
    - webhook-server.webhook-system.svc.cluster.local
  issuerRef:
    name: selfsigned-issuer
    kind: Issuer

Option 2: Generate certs at startup and patch the caBundle field yourself. Works but means your webhook deployment needs extra RBAC to patch its own configuration. I don't recommend this.

Registering the Webhook

apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
  name: cost-center-injector
  annotations:
    cert-manager.io/inject-ca-from: webhook-system/webhook-server-cert
webhooks:
  - name: cost-center.myorg.com
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Ignore   # don't block deployments if webhook is down
    rules:
      - apiGroups: ["apps"]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["deployments"]
    clientConfig:
      service:
        name: webhook-server
        namespace: webhook-system
        path: /mutate

failurePolicy: Ignore is a conscious choice. In production I don't want a webhook outage to take down deployments. For security-critical validating webhooks — ones that enforce hard policy — I use Fail instead.

Why This Matters

Admission webhooks are how you implement cluster-wide standards without relying on every developer to remember. Mandatory labels for cost allocation, resource limit defaults, sidecar injection for observability — none of this works if it's just documentation. Webhooks make it structural.

The Go implementation is not complicated once you've seen it once. The surface area is small: decode an AdmissionReview, make a decision, encode a response. Everything else — TLS, registration, RBAC — is infrastructure that you set up once.