🔌 How Dynamic Admission Webhooks Work
Dynamic admission webhooks intercept API server requests after authentication and authorisation but before the object is persisted to etcd. They come in two flavours:
🔧 Mutating
Can modify the object. Called first. Used to inject sidecars, set defaults, add labels/annotations. Returns a JSON Patch.
✅ Validating
Read-only — can only allow or deny. Called after all mutating webhooks. Used to enforce policy. Returns allowed: true/false.
MutatingWebhookConfiguration — registering your webhook
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: sidecar-injector
annotations:
# cert-manager injects the CA bundle automatically
cert-manager.io/inject-ca-from: webhook-system/sidecar-injector-cert
webhooks:
- name: inject.sidecar.myorg.example.com
admissionReviewVersions: ["v1"]
sideEffects: None # required for dry-run support
failurePolicy: Ignore # Fail = block pod creation if webhook down
reinvocationPolicy: IfNeeded
clientConfig:
service:
name: sidecar-injector-svc
namespace: webhook-system
path: /mutate-pods
caBundle: "" # populated by cert-manager annotation
rules:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
operations: ["CREATE"] # only on pod creation
namespaceSelector:
matchLabels:
inject-sidecar: "true" # opt-in per namespace
objectSelector:
matchExpressions:
- key: sidecar.myorg.example.com/inject
operator: NotIn
values: ["false"] # skip pods that opt-out
✅ Validating Webhook — Policy Enforcement
A validating webhook can only accept or reject — it cannot modify the object. It runs after all mutating webhooks, so it sees the final mutated form. This is the right place for policy enforcement:
// Validating webhook: reject pods requesting privileged mode
func (v *PodValidator) Handle(ctx context.Context, req admission.Request) admission.Response {
pod := &corev1.Pod{}
v.decoder.Decode(req, pod)
for _, c := range pod.Spec.Containers {
if c.SecurityContext != nil &&
c.SecurityContext.Privileged != nil &&
*c.SecurityContext.Privileged {
return admission.Denied(fmt.Sprintf(
"container %q requests privileged mode — policy violation",
c.Name,
))
}
}
for _, c := range pod.Spec.InitContainers {
if c.SecurityContext != nil &&
c.SecurityContext.RunAsUser != nil &&
*c.SecurityContext.RunAsUser == 0 {
return admission.Denied(fmt.Sprintf(
"init container %q runs as root — policy violation",
c.Name,
))
}
}
// Emit a warning without blocking (K8s 1.19+)
resp := admission.Allowed("pod accepted")
if pod.Spec.SecurityContext == nil {
resp.Warnings = []string{"no pod-level securityContext set — consider adding it"}
}
return resp
}
🔐 TLS — Webhooks Require HTTPS
The API server only calls webhook endpoints over TLS. You must serve your webhook with a valid certificate. The recommended production approach is cert-manager with a self-signed CA:
# cert-manager Certificate for the webhook server
apiVersion: cert-manager.io/v1
kind: Certificate
metadata:
name: sidecar-injector-cert
namespace: webhook-system
spec:
secretName: sidecar-injector-tls
dnsNames:
- sidecar-injector-svc.webhook-system.svc
- sidecar-injector-svc.webhook-system.svc.cluster.local
issuerRef:
name: selfsigned-issuer
kind: ClusterIssuer
# The webhook Deployment mounts the TLS secret
volumes:
- name: tls
secret:
secretName: sidecar-injector-tls
volumeMounts:
- name: tls
mountPath: /tmp/k8s-webhook-server/serving-certs
readOnly: true
⚠️ Failure Policies — The Critical Safety Setting
| failurePolicy | Webhook down / timeout | When to use |
|---|---|---|
| Fail | Request is rejected — pod cannot be created | Security-critical validating webhooks (e.g. block privileged containers). Accept that pods can't be created if webhook is down. |
| Ignore | Request is allowed through without calling webhook | Best-effort mutating webhooks (e.g. logging sidecar injection). Cluster stability more important than guaranteed injection. |
- Fail + webhook crash = nobody can create pods in matched namespaces. Always have a
namespaceSelectorthat excludeskube-systemand your webhook's own namespace to avoid a deadlock where the webhook can't self-heal. - Ignore + security webhook = an outage silently disables your security controls. Use
Failfor security-critical checks. - Timeout default = 10s. Add a
timeoutSeconds: 5to fail fast and reduce admission latency.
Key configuration best practices
| Setting | Recommended value | Why |
|---|---|---|
sideEffects | None | Required for dry-run support; use NoneOnDryRun if webhook has side effects |
timeoutSeconds | 5 | Default 10s causes slow pod scheduling; fail fast |
namespaceSelector | Exclude kube-system, webhook NS | Prevent deadlock if webhook pod needs to be created |
reinvocationPolicy | IfNeeded | Re-run mutating webhook if another mutating webhook changed the object |
matchPolicy | Equivalent | Match both exact and equivalent API versions (v1 and v1beta1) |
💉 Writing the Sidecar Injector
The webhook receives an AdmissionReview request, inspects the Pod, and returns a JSON Patch that adds the sidecar container. Here's a complete Go implementation using controller-runtime's webhook handler:
Webhook handler (using controller-runtime)
// internal/webhook/pod_sidecar_injector.go
package webhook
import (
"context"
"encoding/json"
"net/http"
corev1 "k8s.io/api/core/v1"
"sigs.k8s.io/controller-runtime/pkg/webhook/admission"
)
type SidecarInjector struct {
decoder *admission.Decoder
}
// Handle is called for every matching Pod CREATE
func (s *SidecarInjector) Handle(ctx context.Context, req admission.Request) admission.Response {
pod := &corev1.Pod{}
if err := s.decoder.Decode(req, pod); err != nil {
return admission.Errored(http.StatusBadRequest, err)
}
// Skip if sidecar already present (idempotency)
for _, c := range pod.Spec.Containers {
if c.Name == "fluent-bit-sidecar" {
return admission.Allowed("sidecar already present")
}
}
// Build the sidecar container spec
sidecar := corev1.Container{
Name: "fluent-bit-sidecar",
Image: "fluent/fluent-bit:2.2",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("64Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceMemory: resource.MustParse("128Mi"),
},
},
VolumeMounts: []corev1.VolumeMount{{
Name: "varlog",
MountPath: "/var/log",
}},
}
// Inject the sidecar and a shared volume
pod.Spec.Containers = append(pod.Spec.Containers, sidecar)
pod.Spec.Volumes = append(pod.Spec.Volumes, corev1.Volume{
Name: "varlog",
VolumeSource: corev1.VolumeSource{
EmptyDir: &corev1.EmptyDirVolumeSource{},
},
})
// Annotate so we know injection happened
if pod.Annotations == nil {
pod.Annotations = map[string]string{}
}
pod.Annotations["sidecar.myorg.example.com/injected"] = "true"
// Marshal the modified pod and return a JSON Patch
marshaledPod, err := json.Marshal(pod)
if err != nil {
return admission.Errored(http.StatusInternalServerError, err)
}
return admission.PatchResponseFromRaw(req.Object.Raw, marshaledPod)
}
Registering with the manager (main.go)
// Register the webhook handler at a path
mgr.GetWebhookServer().Register("/mutate-pods", &webhook.Admission{
Handler: &SidecarInjector{
decoder: admission.NewDecoder(mgr.GetScheme()),
},
})
// Or using Kubebuilder markers in the handler struct:
// +kubebuilder:webhook:path=/mutate-pods,mutating=true,failurePolicy=ignore,
// sideEffects=None,groups="",resources=pods,verbs=create,versions=v1,
// name=inject.sidecar.myorg.example.com,admissionReviewVersions=v1
Understanding JSON Patch
The webhook returns a RFC 6902 JSON Patch — a list of operations that describe the diff between the original and modified object. controller-runtime's PatchResponseFromRaw generates this automatically, but it's useful to understand the format:
// What the API server receives back from the webhook:
{
"apiVersion": "admission.k8s.io/v1",
"kind": "AdmissionReview",
"response": {
"uid": "req-uid-abc123",
"allowed": true,
"patchType": "JSONPatch",
"patch": "W3sib3AiOiJhZGQiLCJwYXRoIjoiL3NwZWMvY29udGFpbmVycy8tIiwidmFsdWUiOnt..."
// base64-encoded JSON Patch — decoded it looks like:
// [
// {"op":"add","path":"/spec/containers/-","value":{"name":"fluent-bit-sidecar",...}},
// {"op":"add","path":"/spec/volumes/-","value":{"name":"varlog","emptyDir":{}}},
// {"op":"add","path":"/metadata/annotations/sidecar.myorg.example.com~1injected","value":"true"}
// ]
}
}
/ in a key is escaped as ~1 and ~ as ~0. So the annotation key sidecar.myorg.example.com/injected becomes path /metadata/annotations/sidecar.myorg.example.com~1injected.
🧠 Knowledge Check
Q1. In what order do mutating and validating webhooks run, and what is the key difference in what they can do?
Q2. Your security-critical validating webhook uses failurePolicy: Ignore. The webhook pod crashes. What happens to pod creation in matched namespaces?
Q3. Why must the namespaceSelector on a webhook with failurePolicy: Fail exclude the webhook's own namespace?
Q4. In a JSON Patch path, how is the annotation key myorg.io/injected represented?
/metadata/annotations/myorg.io/injected — slashes are allowed in paths/metadata/annotations/myorg.io%2Finjected — URL-encoded/metadata/annotations/myorg.io~1injected — / is escaped as ~1 per JSON Pointer spec