Kyverno released a threat model and hardening guide in April 2026, produced by the security consultancy ControlPlane in partnership with the CNCF and reviewed by Kyverno’s own maintainers (Jim Bugwadia and Shuting Zhao among them). It’s 75 pages, written by ControlPlane’s Samuel Holmes and Tom Cope and signed off by their CEO Andrew Martin, and it targets Kyverno v1.17.1. That’s about a month after Kyverno hit CNCF Graduated status.

It walks through two reference deployments, a single soft-multi-tenant cluster and a hub-and-spoke multi-cluster fleet, both deliberately seeded with the kind of misconfiguration real platform teams actually ship. From there it derives thirteen threats, four attack trees, and thirteen hardening recommendations. Not one of them is a bug in Kyverno’s code. There’s exactly one CVE cited in the whole document. Every other finding is a default, a flag, or a permission somebody forgot to lock down. The report says as much outright in its first paragraph: “the most significant risks often arise from misconfigurations rather than underlying software vulnerabilities.”

If you run Kyverno anywhere near production, here’s what actually matters out of it.


Fail open, by default

Kyverno enforces at admission time through a webhook the API server calls before a resource gets persisted. If that webhook is configured with failurePolicy: Ignore and the admission controller fails to respond in time, the API server admits the request as if no webhook existed at all. Not a warning, not a retry — silent pass-through.

That makes Kyverno’s own uptime a security control, which is an easy thing to miss until someone points it out. An attacker doesn’t need to compromise Kyverno to exploit this. Exhausting resources in the kyverno namespace, or just an unlucky dependency failure during a rollout, is enough to open the window. Every unsigned image, every privileged pod, everything gets through for as long as the outage lasts.

Two settings get you into this state without anyone touching failurePolicy directly: --autoUpdateWebhooks=false defaults every webhook config to Ignore, and --forceFailurePolicyIgnore forces it globally, overriding whatever you set per-policy. Both show up in real installs, usually from someone trying to avoid cluster-wide admission failures during initial rollout and never revisiting it.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-run-as-nonroot
spec:
  webhookConfiguration:
    failurePolicy: Fail
  failureAction: Enforce
  rules:
    - name: check-runasnonroot
      # ...

Run three replicas per controller, set failurePolicy: Fail everywhere in prod, and actually look at webhookTimeoutSeconds. If normal request latency is close to the timeout, you’re one slow day away from triggering this yourself.


Audit mode is not enforcement

Here’s the one that gets almost everyone at some point: failureAction defaults to Audit, not Enforce. A policy in Audit mode records violations in a PolicyReport and does nothing else. Kyverno’s own sample policy library ships mostly in Audit mode too, specifically so it doesn’t break new users' clusters on first install. Deploy those samples as-is and you’ve got ClusterPolicy resources running, reports generating, dashboards showing green — and zero requests actually blocked.

The report calls this out as a particular blind spot in regulated environments, and it’s easy to see why: PolicyReport results get spread across namespaces and merged per-namespace, so spotting a new violation without dedicated tooling watching for it is genuinely hard. The mere presence of a ClusterPolicy is not evidence anything is enforced. You have to check failureAction explicitly.

There’s a wrinkle even after you flip the switch. Moving a policy from Audit to Enforce doesn’t retroactively block resources that already violate it. They keep running until the next update, at which point the update itself gets blocked. If you don’t want that grace period, validate.allowExistingViolations: false closes it.

This same gap shows up across environments, not just within one cluster. A policy tested in a dev cluster running Audit mode with a pile of exceptions tells you almost nothing about how it’ll behave in a prod cluster running Enforce with none. “It passed in dev” is not validation. kyverno apply --cluster checks a policy against live cluster resources before you flip it to Enforce, and kyverno test runs in your pipeline before that. Neither replaces an actual review gate between environments, but running the same YAML twice and calling it tested is worse than doing nothing.


PolicyException: the quiet bypass

PolicyException is Kyverno’s mechanism for exempting a workload from a policy without editing the policy itself. It’s opt-in: you need enablePolicyException set. But once it’s on, a second flag matters just as much: exceptionNamespace. Leave it unset and exceptions from any namespace get evaluated against any policy, which is a much bigger surface than most people creating the exception realize.

This isn’t hypothetical. CVE-2024-48921 is exactly this: without namespace scoping, a user with permission to create exceptions in one namespace could write a PolicyException bypassing ClusterPolicies in namespaces they didn’t own. Straightforward privilege escalation, and it’s the direct reason the guide treats exceptionNamespace as non-negotiable.

It gets quieter than a normal bypass, too. A matched exception produces a “skip” in PolicyReport, not a “fail”. With reportResult: pass set, an exempted resource shows up as passing, indistinguishable from something that actually meets the policy. Exceptions apply to background scans by default as well, so a new exception can make an existing, already-reported violation vanish from your compliance dashboard without the underlying resource changing at all.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
  name: allow-legacy-app
  namespace: exceptions   # must equal --exceptionNamespace on the controller
spec:
  exceptions:
    - policyName: require-run-as-nonroot
      ruleNames: ["autogen-check-runasnonroot"]
  match:
    any:
      - resources:
          namespaces: ["legacy-billing"]
          names: ["legacy-app-*"]

Restrict who can write these to platform SRE, scope them to specific names and namespaces rather than broad selectors, and put an expiry on them. Exceptions created under incident pressure to unblock a deploy have a habit of outliving the incident by a year.

These three defaults compound. None of them individually requires touching Kyverno’s code, and stacked together they add up to enforcement that looks intact from the outside while doing nothing.

AdmissionfailurePolicy: Ignorewebhook times out → request admitted anyway
Policy modefailureAction: Auditthe default — violation logged, nothing blocked
Exception scopeexceptionNamespace unsetany namespace can exempt any workload
↓ compound ↓
A privileged or unsigned workload runs in production, unblocked
PolicyReport and dashboards still read green — nothing fires an alert
the fix
AdmissionfailurePolicy: Fail+ 3 replicas per controller
Policy modefailureAction: Enforcereviewed per policy, not left on default
Exception scopeexceptionNamespace set+ RBAC-restricted, reviewed, time-boxed

Kyverno doesn’t watch its own namespace

By default, kyverno’s own namespace is excluded from Kyverno’s own admission webhook. No ClusterPolicy rule applies to anything created inside it. That’s controlled by config.excludeKyvernoNamespace, and it defaults to true on purpose: without the exclusion, a Kyverno outage combined with failurePolicy: Fail could stop Kyverno’s own pods from being recreated, locking you out of the exact tool you’d need to fix things.

Fair trade-off, but it means anyone with plain Kubernetes RBAC write access to the kyverno namespace operates entirely outside the policy engine. Modifying controller ConfigMaps, touching service account tokens, deploying a pod straight into the enforcement environment. None of it needs cluster-admin, none of it needs a cluster-scoped policy resource, and Kyverno’s own policies have no say in any of it. It’s plain RBAC or nothing.

Default installs don’t help here either. There’s no NetworkPolicy on the kyverno namespace by default, so the webhook endpoint and metrics port are reachable from any tenant namespace on the same cluster, TLS-secured but with no mutual auth to confirm the caller is actually the API server. If Kyverno’s namespace is excluded from its own enforcement, restricted RBAC and a NetworkPolicy are the only two things actually holding that door shut.


What Kyverno can never protect

ValidatingWebhookConfiguration and MutatingWebhookConfiguration are native Kubernetes objects under admissionregistration.k8s.io, not Kyverno resources. Kyverno’s own ClusterPolicy can’t govern write access to them, structurally — there’s nothing for a ClusterPolicy rule to intercept. Whoever can write to these objects can narrow what Kyverno intercepts, redirect a webhook to their own endpoint, shrink the timeout until it triggers the Ignore bypass from earlier, or just delete the config. Kubernetes RBAC is the only thing standing in the way, and a native ValidatingAdmissionPolicy is the only independent layer you can add on top of it.

There’s a second, less obvious version of the same problem in how Kyverno’s RBAC is built. Controllers get their permissions through an aggregated ClusterRole model. Labels like rbac.kyverno.io/aggregate-to-admission-controller: "true" mark a ClusterRole as contributing to a controller’s effective permissions. Any principal who can create a ClusterRole carrying that label silently extends what the controller can do, without ever touching the named role anyone thinks they’re auditing. Reviewing “who holds the admission-controller role” misses this entirely — you have to audit who can create ClusterRoles with these labels at all.


Sign and verify, not either

Two different artifacts need the same treatment here: container images, and Kyverno’s own policy bundles.

For images, verifyImages rules or the newer CEL-based ImageValidatingPolicy (introduced in v1.17) check a cosign signature before admitting an image. Neither is on by default, and the ways this quietly stops working are mundane: failureAction: Audit left on during rollout and never flipped, required: false letting unsigned images through without error, an incomplete imageReferences glob that misses a registry someone added later, or a failurePolicy: Ignore on the verification rule specifically. This is separate from the Ignore from earlier: it skips verification if the signature registry is unreachable while Kyverno itself stays perfectly healthy.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
verifyImages:
  - imageReferences:
      - "registry.company.com/*"
    required: true
    failurePolicy: Fail
    mutateDigest: true
    attestors:
      - entries:
          - keys:
              publicKeys: |-
                -----BEGIN PUBLIC KEY-----
                ...
                -----END PUBLIC KEY-----                

mutateDigest: true is worth calling out specifically — it pins the image to a digest at admission time, so a tag that gets repointed after verification can’t quietly serve different content later.

Policy bundles need the identical pairing: sign before storage, verify before applying, and having only one of the two protects nothing. If bundles are unsigned, or signed but nothing checks the signature on retrieval, write access to the registry is enough to modify what gets applied fleet-wide — no cluster access required at all. The reference setup here is cosign with an HSM-backed key, then cosign verify on every cluster before kubectl apply.

1
2
3
4
cosign verify-blob \
  --key cosign.pub \
  --signature policy-bundle.sig \
  policy-bundle.tar.gz || exit 1

It gets worse if the same registry principal can write to both the image repos and the policy bundle repos — a compromised CI credential can push a malicious image and edit the policy that would have caught it in one motion, with no gap between the two events for anyone to notice. Repo-level RBAC separating those two write paths matters more than it sounds like it should.

In a hub-and-spoke fleet, the hub concentrates all of this in one place: signing infrastructure access, the distribution pipeline, and — in the policy-reporter namespace — every spoke cluster’s API credentials, stored as ordinary Kubernetes Secrets. Kubernetes Secrets are base64, not encrypted, unless you’ve turned on etcd encryption at rest. A hub compromise there isn’t “one cluster down,” it’s authenticated read access to every spoke in the fleet simultaneously, which is a meaningfully worse outcome than any single spoke getting popped on its own.


Turning the admission controller into an SSRF probe

This is the one I didn’t expect going in. A ClusterPolicy rule can include an apiCall.service context entry (or the CEL equivalent via the HTTP library) that calls out to an arbitrary URL during admission evaluation.

1
2
3
4
5
6
context:
  - name: external-check
    apiCall:
      service:
        url: https://internal-svc.default.svc.cluster.local/check
      method: GET

If that endpoint is unreachable, you’re stuck with the same fork from earlier: failurePolicy: Fail blocks legitimate deployments, failurePolicy: Ignore silently drops enforcement. Either way, an external dependency is now deciding your admission behavior. Worse, a POST call can carry fields from the admission request — labels, annotations, spec values — off-cluster, and there’s no mutual auth on the way out, just optional custom headers.

The part worth sitting with: writing a ClusterPolicy rule is enough to do this. No component compromise, no cluster-admin. A principal with ordinary policy-write access can point apiCall.service at an attacker-controlled URL, and from then on every admission request matching that rule fires an outbound HTTP call — probing internal services, reaching for a cloud metadata endpoint, or just exfiltrating whatever’s in the request body. The admission controller becomes the SSRF client, and the attacker never has to touch it directly.

Kyverno v1.17.0 shipped a default block list for cloud metadata endpoints specifically because of this, which helps with the most damaging single target. It doesn’t help with everything else reachable on the internal network. Scope apiCall.service to in-cluster services where you can, use --httpBlocklist or --httpAllowlist to pin down what’s reachable at all, and put a NetworkPolicy egress allowlist on the kyverno namespace so a malicious rule has nowhere to actually call.


Summary

Check Why it matters
failurePolicy: Fail on every webhook, 3 replicas per controller A crashed or overloaded Kyverno should block deployments, not wave them through. Never use --forceFailurePolicyIgnore in prod
failureAction: Enforce, reviewed per policy The default is Audit. Reports without blocking is not enforcement, no matter what the dashboard implies
Promote through a staging env that mirrors prod’s Enforce posture “Passed in dev” means nothing if dev runs Audit with broad exceptions. Use kyverno apply --cluster and kyverno test before promoting
Set exceptionNamespace, restrict PolicyException writes to platform SRE Without a fixed namespace, exceptions from anywhere bypass policy everywhere. This is CVE-2024-48921
Restricted RBAC + Pod Security Admission + NetworkPolicy on kyverno itself The namespace is excluded from its own webhook by design; RBAC and network isolation are the only backstop
Restrict who can create ClusterRoles with rbac.kyverno.io/aggregate-to-* labels These silently extend controller permissions, bypassing an audit of the named roles alone
Add ValidatingAdmissionPolicy on webhook configs and cluster policy resources Native admissionregistration.k8s.io objects can’t be governed by Kyverno’s own ClusterPolicy
verifyImages/ImageValidatingPolicy with required: true, mutateDigest: true Audit-mode or optional verification lets an unsigned image through with no error
Sign policy bundles with cosign + HSM keys, verify on every cluster before apply Signing without verification (or the reverse) stops nothing
Separate registry write access for app images vs. policy bundles One compromised CI credential shouldn’t be able to tamper both the payload and the check meant to catch it
Scope apiCall.service to in-cluster targets, use --httpBlocklist/egress NetworkPolicy ClusterPolicy write access alone is enough to turn the admission controller into an SSRF client

None of this needed a zero-day. Every path in the report starts from a default Kyverno ships with, or a flag someone flipped during a rollout and never went back to. That’s the uncomfortable part — the fix isn’t a patch, it’s going back through settings you probably haven’t looked at since the day you installed the chart.