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/webhookpackage 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.