🔌 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.

kubectl apply API request Authn/Authz RBAC check Mutating Webhooks inject/patch Validating Webhooks allow/deny etcd write persisted

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

failurePolicyWebhook down / timeoutWhen 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.
🔴 Failure policy pitfalls
  • Fail + webhook crash = nobody can create pods in matched namespaces. Always have a namespaceSelector that excludes kube-system and 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 Fail for security-critical checks.
  • Timeout default = 10s. Add a timeoutSeconds: 5 to fail fast and reduce admission latency.

Key configuration best practices

SettingRecommended valueWhy
sideEffectsNoneRequired for dry-run support; use NoneOnDryRun if webhook has side effects
timeoutSeconds5Default 10s causes slow pod scheduling; fail fast
namespaceSelectorExclude kube-system, webhook NSPrevent deadlock if webhook pod needs to be created
reinvocationPolicyIfNeededRe-run mutating webhook if another mutating webhook changed the object
matchPolicyEquivalentMatch 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"}
    // ]
  }
}
💡 JSON Pointer encoding In JSON Patch paths, / 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?

A) Validating runs first to check policy; mutating runs second to fix violations
B) They run in parallel — order is non-deterministic
C) Mutating runs first and can modify objects (JSON Patch); validating runs second and can only allow/deny
D) Both run simultaneously — whichever responds first wins

Q2. Your security-critical validating webhook uses failurePolicy: Ignore. The webhook pod crashes. What happens to pod creation in matched namespaces?

A) Pod creation is blocked until the webhook recovers
B) Pods are allowed through without validation — security policy is silently bypassed
C) The API server queues the requests until the webhook recovers
D) An alert fires but pods are still validated against cached policy

Q3. Why must the namespaceSelector on a webhook with failurePolicy: Fail exclude the webhook's own namespace?

A) To reduce latency — the webhook skips namespaces it manages
B) RBAC prevents the webhook from checking its own namespace
C) To prevent a deadlock where the crashed webhook blocks its own pod from being rescheduled
D) Webhooks can only inspect namespaces they don\'t live in

Q4. In a JSON Patch path, how is the annotation key myorg.io/injected represented?

A) /metadata/annotations/myorg.io/injected — slashes are allowed in paths
B) /metadata/annotations/myorg.io%2Finjected — URL-encoded
C) /metadata/annotations/myorg.io~1injected/ is escaped as ~1 per JSON Pointer spec
D) Annotation keys with slashes cannot be set via JSON Patch