DevOps

Kubernetes Admission Controllers in 2026: Writing Custom Go Webhooks for Cluster Policy Enforcement

Ruslan Ismailov Published 18 min read
K

What Are Admission Controllers and How They Work in Kubernetes

Admission Controllers are Kubernetes plugins that intercept requests to the API server after authentication and authorization, but before the object is persisted to etcd. They allow you to validate, mutate, or reject requests to create, update, or delete cluster resources.

The request processing chain looks like this:

  1. A client sends a request to kube-apiserver.
  2. Authentication and authorization (RBAC) are performed.
  3. The request enters the Admission Controllers chain.
  4. Mutating controllers modify the object (e.g., adding labels).
  5. Validating controllers check the final state of the object.
  6. The object is saved to etcd and distributed across the cluster.

Built-in controllers (LimitRanger, ResourceQuota, PodSecurity) cover basic scenarios, but for organization-specific policies you need Webhook Admission Controllers — external HTTP services implementing custom logic. In 2026, this is standard practice for Platform Engineering teams working with Kubernetes.

ValidatingWebhookConfiguration vs MutatingWebhookConfiguration

Kubernetes supports two types of webhooks:

  • MutatingAdmissionWebhook — modifies an object before it is saved. Used for automatically injecting sidecar containers, enforcing labels/annotations, and setting default resource limits.
  • ValidatingAdmissionWebhook — only validates an object and returns an allow or deny decision. Cannot modify the object. Used for enforcing security policies: blocking untagged images, enforcing naming conventions, requiring mandatory labels.

The key rule: if you need to fix an object — use Mutating. If you need to reject an invalid object — use Validating. In practice, both types are often deployed together: Mutating sets defaults, Validating checks the final state. This is especially important when writing Kubernetes policies in Go, where a clear separation of concerns simplifies testing.

Writing a Custom Admission Webhook in Go

Project Structure

A minimal project structure for a Go Admission Webhook:

admission-webhook/
├── cmd/
│   └── webhook/
│       └── main.go
├── internal/
│   ├── handler/
│   │   ├── validate.go
│   │   └── mutate.go
│   └── policy/
│       ├── image_tag.go
│       └── resource_limits.go
├── deploy/
│   ├── deployment.yaml
│   ├── service.yaml
│   └── webhook-config.yaml
├── certs/
│   └── generate.sh
├── go.mod
└── go.sum

go.mod Dependencies

module github.com/yourorg/admission-webhook

go 1.23

require (
    k8s.io/api v0.30.0
    k8s.io/apimachinery v0.30.0
    sigs.k8s.io/controller-runtime v0.18.0
)

Entry Point: main.go

package main

import (
    "crypto/tls"
    "log"
    "net/http"

    "github.com/yourorg/admission-webhook/internal/handler"
)

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

    cert, err := tls.LoadX509KeyPair("/certs/tls.crt", "/certs/tls.key")
    if err != nil {
        log.Fatalf("Failed to load TLS certs: %v", err)
    }

    server := &http.Server{
        Addr: ":8443",
        TLSConfig: &tls.Config{
            Certificates: []tls.Certificate{cert},
            MinVersion:   tls.VersionTLS13,
        },
        Handler: mux,
    }

    log.Println("Starting webhook server on :8443")
    if err := server.ListenAndServeTLS("", ""); err != nil {
        log.Fatalf("Server failed: %v", err)
    }
}

Handling AdmissionReview

Kubernetes sends an AdmissionReview object to the webhook and expects the same object in response with the response field populated. A basic handler for a validating webhook:

package handler

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

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

    "github.com/yourorg/admission-webhook/internal/policy"
)

func Validate(w http.ResponseWriter, r *http.Request) {
    body, err := io.ReadAll(r.Body)
    if err != nil {
        http.Error(w, "failed to read body", http.StatusBadRequest)
        return
    }

    var review admissionv1.AdmissionReview
    if err := json.Unmarshal(body, &review); err != nil {
        http.Error(w, "failed to unmarshal AdmissionReview", http.StatusBadRequest)
        return
    }

    req := review.Request
    var pod corev1.Pod
    if err := json.Unmarshal(req.Object.Raw, &pod); err != nil {
        http.Error(w, "failed to unmarshal Pod", http.StatusBadRequest)
        return
    }

    allowed, reason := policy.ValidateImageTags(pod)
    review.Response = &admissionv1.AdmissionResponse{
        UID:     req.UID,
        Allowed: allowed,
    }
    if !allowed {
        review.Response.Result = &metav1.Status{
            Message: reason,
            Code:    403,
        }
    }

    resp, err := json.Marshal(review)
    if err != nil {
        log.Printf("Error marshaling response: %v", err)
        http.Error(w, fmt.Sprintf("failed to marshal response: %v", err), http.StatusInternalServerError)
        return
    }

    w.Header().Set("Content-Type", "application/json")
    w.Write(resp)
}

Kubernetes Policy Examples in Go

Policy 1: Block Images Without Tags

Images without an explicit tag (e.g., nginx instead of nginx:1.27.0) use latest by default, which breaks deployment reproducibility. Let's implement the check:

package policy

import (
    "fmt"
    "strings"

    corev1 "k8s.io/api/core/v1"
)

// ValidateImageTags checks that all containers use explicit tags
// and do not use the 'latest' tag.
func ValidateImageTags(pod corev1.Pod) (bool, string) {
    allContainers := append(pod.Spec.InitContainers, pod.Spec.Containers...)
    for _, c := range allContainers {
        image := c.Image
        // Check for the presence of a tag
        parts := strings.Split(image, ":")
        if len(parts) < 2 || parts[len(parts)-1] == "" {
            return false, fmt.Sprintf(
                "container '%s' uses image '%s' without explicit tag",
                c.Name, image,
            )
        }
        // Disallow the 'latest' tag
        tag := parts[len(parts)-1]
        if tag == "latest" {
            return false, fmt.Sprintf(
                "container '%s' uses 'latest' tag, which is not allowed",
                c.Name,
            )
        }
    }
    return true, ""
}

Policy 2: Enforce Resource Limits (Mutating Webhook)

A mutating webhook adds resource limits to containers that do not have them defined. JSON Patch is used to modify the object:

package handler

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

    admissionv1 "k8s.io/api/admission/v1"
    corev1 "k8s.io/api/core/v1"
    "k8s.io/apimachinery/pkg/api/resource"
)

type patchOp struct {
    Op    string      `json:"op"`
    Path  string      `json:"path"`
    Value interface{} `json:"value,omitempty"`
}

func Mutate(w http.ResponseWriter, r *http.Request) {
    body, _ := io.ReadAll(r.Body)

    var review admissionv1.AdmissionReview
    json.Unmarshal(body, &review)

    var pod corev1.Pod
    json.Unmarshal(review.Request.Object.Raw, &pod)

    var patches []patchOp
    defaultCPU := resource.MustParse("500m")
    defaultMem := resource.MustParse("256Mi")

    for i, c := range pod.Spec.Containers {
        if c.Resources.Limits == nil {
            patches = append(patches, patchOp{
                Op:   "add",
                Path: fmt.Sprintf("/spec/containers/%d/resources/limits", i),
                Value: corev1.ResourceList{
                    corev1.ResourceCPU:    defaultCPU,
                    corev1.ResourceMemory: defaultMem,
                },
            })
        }
    }

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

    review.Response = &admissionv1.AdmissionResponse{
        UID:       review.Request.UID,
        Allowed:   true,
        Patch:     patchBytes,
        PatchType: &patchType,
    }

    resp, _ := json.Marshal(review)
    w.Header().Set("Content-Type", "application/json")
    w.Write(resp)
    _ = log.Writer()
}

Policy 3: Enforce Required Labels

A mutating webhook can also easily add required labels. Simply build a JSON Patch targeting the /metadata/labels path:

func buildLabelPatch(existingLabels map[string]string) []patchOp {
    required := map[string]string{
        "app.kubernetes.io/managed-by": "platform-team",
        "env":                          "production",
    }
    var ops []patchOp
    if existingLabels == nil {
        ops = append(ops, patchOp{Op: "add", Path: "/metadata/labels", Value: required})
        return ops
    }
    for k, v := range required {
        if _, exists := existingLabels[k]; !exists {
            // Escape '/' in keys for JSON Pointer (RFC 6901)
            safeKey := strings.ReplaceAll(k, "/", "~1")
            ops = append(ops, patchOp{
                Op:    "add",
                Path:  "/metadata/labels/" + safeKey,
                Value: v,
            })
        }
    }
    return ops
}

TLS and Webhook Security

Kubernetes requires all webhooks to operate over HTTPS. The API server validates the webhook certificate against the CA Bundle specified in the WebhookConfiguration.

Generating Self-Signed Certificates

#!/bin/bash
# certs/generate.sh

SERVICE="admission-webhook"
NAMESPACE="webhook-system"
SECRET="admission-webhook-tls"

# Generate CA
openssl genrsa -out ca.key 4096
openssl req -new -x509 -days 3650 -key ca.key \
    -subj "/CN=Admission Webhook CA" \
    -out ca.crt

# Generate server key and CSR
openssl genrsa -out tls.key 4096
openssl req -new -key tls.key \
    -subj "/CN=${SERVICE}.${NAMESPACE}.svc" \
    -out tls.csr

# Sign the certificate
cat > ext.cnf <

Certificate Rotation

In production, it is recommended to use cert-manager for automatic certificate rotation. Cert-manager supports the cert-manager.io/inject-ca-from annotation to automatically update the caBundle in WebhookConfiguration. This eliminates manual rotation and reduces the operational burden on Platform Engineering teams.

Deploying the Webhook to Kubernetes

Namespace and RBAC

apiVersion: v1
kind: Namespace
metadata:
  name: webhook-system
  labels:
    app.kubernetes.io/managed-by: platform-team

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: admission-webhook
  namespace: webhook-system
spec:
  replicas: 2
  selector:
    matchLabels:
      app: admission-webhook
  template:
    metadata:
      labels:
        app: admission-webhook
    spec:
      securityContext:
        runAsNonRoot: true
        runAsUser: 65534
      containers:
        - name: webhook
          image: yourorg/admission-webhook:1.2.0
          ports:
            - containerPort: 8443
          resources:
            requests:
              cpu: 100m
              memory: 64Mi
            limits:
              cpu: 200m
              memory: 128Mi
          volumeMounts:
            - name: tls-certs
              mountPath: /certs
              readOnly: true
          livenessProbe:
            httpGet:
              path: /healthz
              port: 8443
              scheme: HTTPS
            initialDelaySeconds: 5
            periodSeconds: 10
      volumes:
        - name: tls-certs
          secret:
            secretName: admission-webhook-tls

Service

apiVersion: v1
kind: Service
metadata:
  name: admission-webhook
  namespace: webhook-system
spec:
  selector:
    app: admission-webhook
  ports:
    - port: 443
      targetPort: 8443

ValidatingWebhookConfiguration

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: image-policy-webhook
webhooks:
  - name: validate-image-tags.webhook-system.svc
    admissionReviewVersions: ["v1"]
    sideEffects: None
    failurePolicy: Fail
    namespaceSelector:
      matchExpressions:
        - key: kubernetes.io/metadata.name
          operator: NotIn
          values: ["kube-system", "webhook-system"]
    rules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
    clientConfig:
      service:
        name: admission-webhook
        namespace: webhook-system
        path: /validate
      caBundle: BASE64_ENCODED_CA_CERT

The failurePolicy: Fail parameter means that if the webhook is unavailable, the request will be rejected. This is the safe behavior for production. For staging, you can use failurePolicy: Ignore.

Testing and Debugging Webhooks

Unit Testing Policies

Policies are easy to test in isolation since they accept and return standard Kubernetes objects:

package policy_test

import (
    "testing"

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

    "github.com/yourorg/admission-webhook/internal/policy"
)

func TestValidateImageTags(t *testing.T) {
    tests := []struct {
        name    string
        image   string
        allowed bool
    }{
        {"valid image with tag", "nginx:1.27.0", true},
        {"image without tag", "nginx", false},
        {"image with latest tag", "nginx:latest", false},
        {"image with sha digest", "nginx@sha256:abc123", true},
    }

    for _, tt := range tests {
        t.Run(tt.name, func(t *testing.T) {
            pod := corev1.Pod{
                ObjectMeta: metav1.ObjectMeta{Name: "test-pod"},
                Spec: corev1.PodSpec{
                    Containers: []corev1.Container{
                        {Name: "app", Image: tt.image},
                    },
                },
            }
            allowed, _ := policy.ValidateImageTags(pod)
            if allowed != tt.allowed {
                t.Errorf("expected allowed=%v, got %v", tt.allowed, allowed)
            }
        })
    }
}

Integration Testing with envtest

The sigs.k8s.io/controller-runtime/pkg/envtest package allows you to run a local kube-apiserver with registered webhooks without a real cluster. This speeds up development iterations.

Debugging in the Cluster

Useful commands for diagnosing webhooks:

  • kubectl logs -n webhook-system deploy/admission-webhook — view webhook logs.
  • kubectl describe validatingwebhookconfiguration image-policy-webhook — inspect the configuration.
  • kubectl get events --field-selector reason=FailedCreate — resource creation errors caused by the webhook.
  • Enable kube-apiserver audit logging with level: RequestResponse to record all AdmissionReview requests and responses.

Integrating Policy Checks into the CI/CD Pipeline

To achieve full policy coverage, a multi-layered defense is built into the CI/CD pipeline. This allows violations to be caught early, without waiting for a deployment to be rejected in the cluster.

Layer 1: Static Manifest Analysis

kube-linter and datree validate YAML manifests against policies right in the repository, before they are applied to Kubernetes. Example step in GitHub Actions:

- name: Lint Kubernetes manifests
  uses: stackrox/kube-linter-action@v1
  with:
    directory: deploy/
    config: .kube-linter.yaml

Layer 2: Webhook Testing in an Ephemeral Cluster

A temporary Kubernetes cluster is spun up in the CI/CD pipeline (e.g., via kind or k3s), the webhook is installed, and integration tests are run. Example .gitlab-ci.yml:

integration-test:
  stage: test
  image: golang:1.23
  services:
    - docker:dind
  script:
    - curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.24.0/kind-linux-amd64
    - chmod +x ./kind && mv ./kind /usr/local/bin/
    - kind create cluster --name ci-cluster
    - kubectl apply -f deploy/
    - go test ./... -tags=integration -v
  after_script:
    - kind delete cluster --name ci-cluster

Layer 3: Policy as Code with OPA/Gatekeeper

For complex scenarios, consider OPA Gatekeeper — it implements an Admission Controller on top of Open Policy Agent. Custom Go webhooks and Gatekeeper coexist well: the former handles specific business logic, while the latter manages declarative policies via ConstraintTemplate.

An Admission Webhook is not a replacement for RBAC — it is an additional layer of defense. A well-designed combination of RBAC, Admission Controllers, Network Policies, and audit logging forms a mature Kubernetes cluster security model.

Conclusion

Writing a custom Admission Webhook in Go is a powerful tool for Platform Engineering teams, enabling centralized control over Kubernetes cluster policies. By 2026, this approach has become the standard: it provides flexibility where built-in mechanisms fall short and integrates naturally into CI/CD pipelines. The keys to success are a clear separation between Mutating and Validating webhooks, correct TLS configuration, comprehensive testing, and minimal privileges for the webhook's service account.

Technologies

Tags

Ruslan Ismailov

Senior Web / Backend Developer. Senior web/backend developer with 9 years of experience. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservices, CI/CD. More about me →