Kubernetes Admission Controllers in 2026: Writing Custom Go Webhooks for Cluster Policy Enforcement
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:
- A client sends a request to kube-apiserver.
- Authentication and authorization (RBAC) are performed.
- The request enters the Admission Controllers chain.
- Mutating controllers modify the object (e.g., adding labels).
- Validating controllers check the final state of the object.
- 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: RequestResponseto 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 →