Policy Enforcement in Kubernetes - OPA Gatekeeper and Kyverno

Enforcing cluster policy with validating admission webhooks - a practical comparison of OPA Gatekeeper and Kyverno with real examples

Introduction

Kubernetes admission controllers intercept requests to the API server after authentication/authorization but before an object is persisted, letting you validate or mutate resources on the way in. Beyond the built-in controllers compiled into kube-apiserver, dynamic admission control lets you register your own webhooks - this guide covers the two dominant policy engines that build on it: OPA Gatekeeper and Kyverno.

How Dynamic Admission Control Works

apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingWebhookConfiguration
metadata:
  name: example-policy-webhook
webhooks:
  - name: validate.example.com
    clientConfig:
      service:
        name: policy-webhook
        namespace: policy-system
        path: /validate
    rules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
    failurePolicy: Fail
    sideEffects: None
    admissionReviewVersions: ["v1"]

Both Gatekeeper and Kyverno register webhooks like this one automatically as part of their installation - you don’t hand-write ValidatingWebhookConfiguration/MutatingWebhookConfiguration resources yourself; you write policies in each tool’s own higher-level format instead, and the tool manages the underlying webhook registration.

failurePolicy: Fail means the API server rejects the request if the webhook is unreachable (fails closed) - the safer default for anything enforcing real security policy, though it does mean a webhook outage can block all matching API operations cluster-wide, which is why both tools recommend running the webhook with multiple replicas and tight resource limits.

OPA Gatekeeper

Gatekeeper policies are written in Rego (the Open Policy Agent query language) and split into two pieces: a reusable ConstraintTemplate defining the policy logic and its parameters, and a Constraint (a CRD Gatekeeper generates from the template) that applies it with specific parameter values.

helm repo add gatekeeper https://open-policy-agent.github.io/gatekeeper/charts
helm install gatekeeper gatekeeper/gatekeeper --namespace gatekeeper-system --create-namespace
apiVersion: templates.gatekeeper.sh/v1
kind: ConstraintTemplate
metadata:
  name: requiredlabels
spec:
  crd:
    spec:
      names:
        kind: RequiredLabels
      validation:
        openAPIV3Schema:
          type: object
          properties:
            labels:
              type: array
              items:
                type: string
  targets:
    - target: admission.k8s.gatekeeper.sh
      rego: |
        package requiredlabels

        violation[{"msg": msg}] {
          required := input.parameters.labels
          provided := {label | input.review.object.metadata.labels[label]}
          missing := required - provided
          count(missing) > 0
          msg := sprintf("missing required labels: %v", [missing])
        }
apiVersion: constraints.gatekeeper.sh/v1beta1
kind: RequiredLabels
metadata:
  name: require-team-label
spec:
  match:
    kinds:
      - apiGroups: [""]
        kinds: ["Namespace"]
  parameters:
    labels: ["team"]

Note the two different API groups: the template is templates.gatekeeper.sh/v1, but the Constraint you actually write (RequiredLabels here) uses constraints.gatekeeper.sh/v1beta1 - Gatekeeper dynamically registers a new CRD under the constraints.gatekeeper.sh group for every ConstraintTemplate you create, named after the template’s crd.spec.names.kind.

Kyverno

Kyverno policies are plain Kubernetes YAML with no separate query language to learn - a real advantage for teams that don’t want to invest in Rego, at the cost of somewhat less expressive power for genuinely complex logic.

helm repo add kyverno https://kyverno.github.io/kyverno/
helm install kyverno kyverno/kyverno --namespace kyverno --create-namespace
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-labels
spec:
  validationFailureAction: Enforce
  rules:
    - name: check-team-label
      match:
        any:
          - resources:
              kinds:
                - Namespace
      validate:
        message: "The label 'team' is required on all namespaces."
        pattern:
          metadata:
            labels:
              team: "?*"

The same policy in Kyverno needs no separate template/constraint split and no Rego - pattern matching ("?*" means “any non-empty value”) is enough for this kind of structural validation. Kyverno can also mutate resources, not just validate them:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: add-default-resources
spec:
  rules:
    - name: default-resource-limits
      match:
        any:
          - resources:
              kinds:
                - Pod
      mutate:
        patchStrategicMerge:
          spec:
            containers:
              - (name): "*"
                resources:
                  limits:
                    +(memory): "512Mi"
                    +(cpu): "500m"

+(memory)/+(cpu) (the + prefix) means “add this field only if it doesn’t already exist” - a Pod that already declares its own memory/CPU limits keeps them; one that doesn’t gets these defaults injected automatically.

Choosing Between Them

  • Kyverno if your team wants policy-as-YAML with no new language to learn, and needs both validating and mutating policies without standing up two separate tools.
  • Gatekeeper if you already have Rego expertise (perhaps from using OPA elsewhere, like an API gateway), or need the full expressiveness of a real policy language for genuinely complex cross-resource logic.

Both support dryrun/audit-only modes (validationFailureAction: Audit in Kyverno, enforcementAction: dryrun on a Gatekeeper Constraint) - always roll a new policy out in audit mode first and review violations before switching to enforcement, since a policy with a subtle bug in enforce mode can block legitimate deployments cluster-wide.

Best Practices

  1. Start every new policy in audit/dry-run mode and review actual violations for at least a few days before enforcing - policies that look correct in isolation often catch legitimate existing workloads you didn’t anticipate.
  2. Exempt system namespaces explicitly (kube-system, the policy engine’s own namespace) - a policy that accidentally blocks the policy engine’s own pods from updating can deadlock the cluster.
  3. Run the webhook with failurePolicy: Fail only once you trust its availability - start with Ignore during initial rollout so a webhook crash doesn’t block unrelated cluster operations, then tighten to Fail once it’s proven stable.
  4. Version-control policies the same as any other manifest - both tools’ policies are just Kubernetes objects, so they belong in the same GitOps workflow as everything else, not managed out-of-band.

Conclusion

Admission control policy is what turns “we have a security standard documented somewhere” into “the API server mechanically rejects anything that violates it” - the gap between the two is exactly where most real-world security incidents in Kubernetes clusters originate (a privileged pod that shouldn’t have been allowed, a namespace with no resource quota, an image pulled from an untrusted registry).

Additional Resources