How Do Validating vs. Mutating Admission Webhooks Differ?
Mutating webhooks run first and can modify the incoming object (e.g., inject sidecars, set defaults); validating webhooks run second and can only accept or r...
Quick Answer:
Mutating webhooks run first and can modify the incoming object (e.g., inject sidecars, set defaults); validating webhooks run second and can only accept or reject — they cannot mutate.
Detailed Answer:
Every API request to Kubernetes passes through an admission chain in this order: Authentication → Authorization → Mutating Admission → Object Schema Validation → Validating Admission → etcd write. Mutating webhooks receive the object, can modify it (returning a JSON Patch), and pass it along — this is how sidecar injectors (like Istio's) work. Validating webhooks receive the (potentially already-mutated) object and return a simple allow/deny decision, optionally with a user-facing message. Because mutating runs before validating, a mutating webhook can fill in defaults that your validating webhook then enforces.
Deep Dive:
Admission Chain Architecture:
kubectl apply → API Server Auth/AuthZ
↓
[MutatingAdmissionWebhook] ← can modify object
↓
Schema Validation (OpenAPI)
↓
[ValidatingAdmissionWebhook] ← allow or deny only
↓
Write to etcd
MutatingWebhookConfiguration example (sidecar injector):
apiVersion: admissionregistration.k8s.io/v1
kind: MutatingWebhookConfiguration
metadata:
name: sidecar-injector
webhooks:
- name: inject.sidecar.mycompany.io
admissionReviewVersions: ["v1"]
clientConfig:
service:
name: sidecar-injector-svc
namespace: platform
path: /mutate
caBundle: <base64-CA>
rules:
- operations: ["CREATE"]
apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
# Only inject if namespace has this label
namespaceSelector:
matchLabels:
sidecar-injection: enabled
# Skip pods that opt out
objectSelector:
matchExpressions:
- key: sidecar.mycompany.io/inject
operator: NotIn
values: ["false"]
failurePolicy: Fail # Reject if webhook is down
sideEffects: None
timeoutSeconds: 5
Webhook server handler (Go) — mutating:
func mutatePod(w http.ResponseWriter, r *http.Request) {
body, _ := io.ReadAll(r.Body)
var review admissionv1.AdmissionReview
json.Unmarshal(body, &review)
pod := &corev1.Pod{}
json.Unmarshal(review.Request.Object.Raw, pod)
// Build JSON patch to inject sidecar container
patch := []map[string]interface{}{
{
"op": "add",
"path": "/spec/containers/-",
"value": corev1.Container{
Name: "logging-agent",
Image: "mycompany/log-agent:v2.1",
Resources: corev1.ResourceRequirements{
Requests: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("10m"),
corev1.ResourceMemory: resource.MustParse("32Mi"),
},
Limits: corev1.ResourceList{
corev1.ResourceCPU: resource.MustParse("50m"),
corev1.ResourceMemory: resource.MustParse("64Mi"),
},
},
},
},
}
patchBytes, _ := json.Marshal(patch)
patchType := admissionv1.PatchTypeJSONPatch
review.Response = &admissionv1.AdmissionResponse{
UID: review.Request.UID,
Allowed: true,
Patch: patchBytes,
PatchType: &patchType,
}
json.NewEncoder(w).Encode(review)
}
ValidatingWebhookConfiguration example:
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
name: policy-enforcer
webhooks:
- name: validate.policy.mycompany.io
admissionReviewVersions: ["v1"]
clientConfig:
service:
name: policy-server
namespace: platform
path: /validate
caBundle: <base64-CA>
rules:
- operations: ["CREATE", "UPDATE"]
apiGroups: ["apps"]
apiVersions: ["v1"]
resources: ["deployments"]
failurePolicy: Fail
sideEffects: None
Key differences table:
| Property | Mutating | Validating | |---|---|---| | Can modify object | ✅ Yes (JSON Patch) | ❌ No | | Execution order | First | Second | | Can reject requests | ✅ | ✅ | | Side effects allowed | Configurable | Must be None/NoneOnDryRun | | Idempotency requirement | Critical | N/A | | Common use case | Inject sidecars, set defaults | Enforce policy, validate labels |
Production lesson: Always set
failurePolicy: Failfor security-critical webhooks andfailurePolicy: Ignorefor optional features. A misconfig that takes your webhook down shouldn't makekubectl applywork differently depending on webhook availability.
Common mistake: Not setting
sideEffects: Noneon webhooks that don't have side effects. This breakskubectl apply --dry-runbecause the API server will skip webhooks marked as having side effects during dry runs.
Self-assessment: Can you draw the admission chain from memory? Can you explain why mutating must run before validating? Can you write the JSON patch format for adding a container?
Follow-up questions: Q3 (Build a webhook), Q4 (OPA/Gatekeeper)
Finished reading?
Mark as complete to claim your +20 XP and track progress.
