Level: Advanced · Time: ~35 min · You’ll want first: a working feel for how the API server calls out to admission webhooks — see Admission controllers: validating & mutating webhooks.
In a nutshell
Think of your Kubernetes API server as the front door to a secure building, and every kubectl apply as someone trying to walk in carrying a workload. Kyverno is the security officer you post at that door — except the rulebook they check against is written in ordinary Kubernetes YAML, not a specialised programming language. When a manifest arrives, Kyverno reads it against your house rules and can do one of four things: wave it through (validate–pass), quietly fix it up before it enters (mutate — e.g. add the CPU/memory limits the author forgot), demand proof of identity before admitting the image (verifyImages — check the Cosign signature), or turn it away at the door (validate–deny). It can even furnish a new room the moment it appears (generate — drop a default-deny NetworkPolicy into every fresh namespace).
Those four verbs — validate, mutate, generate, verifyImages — are the whole engine, and you write them inside a ClusterPolicy, which is just a normal Kubernetes object. That is Kyverno’s headline difference from the other big policy engine, OPA Gatekeeper: with Gatekeeper you learn Rego, a dedicated policy language; with Kyverno you stay in the YAML you already know. The trade is raw expressiveness for approachability, and for the three controls in this lesson — signed images, resource limits, and Pod Security — YAML is more than enough.
The other idea to hold onto is audit versus enforce. A brand-new policy should first run in Audit mode: it watches, records every pass and fail in a report, and blocks nothing. Only once you have read the report and fixed the offenders do you flip it to Enforce, where a failing manifest is actually rejected. Skipping the audit step is the single most common way teams take their own cluster down — we will do it the safe way.
Why a beginner should care: without an admission gate, “our cluster only runs signed, limited, non-root workloads” is a hope propped up by code review and good intentions. With Kyverno it becomes a property the API server guarantees — the bad manifest is refused before it is ever written to etcd, every time, for everyone.
By the end of this lesson you will be able to:
- install Kyverno in a highly-available configuration and see the two admission webhooks it registers;
- write a
ClusterPolicythat verifies Cosign image signatures (keyless or keyed) and pins every image to an immutable digest; - mutate sensible default resource requests/limits into under-specified pods, and validate that limits are never simply omitted;
- enforce the restricted Pod Security Standard with targeted, auditable exemptions instead of a blanket opt-out;
- roll any policy out safely from Audit to Enforce, read the resulting
PolicyReports, and choose afailurePolicythat hardens the cluster without ever wedging it.
A payments platform team gets the finding back from their first real supply-chain audit: anyone with kubectl apply can run :latest from an arbitrary public registry, half the pods have no CPU/memory limits so one bad deploy noisy-neighbours an entire node, and a third of workloads run as root with hostPath mounts. The CISO’s instruction is blunt — “nothing runs in production unless it is our signed image, it stays inside its limits, and it cannot get root on the node.” You can chase that with code review and good intentions, or you can make the cluster itself refuse the bad manifest at the API server. This guide does the latter with Kyverno, the Kubernetes-native policy engine, enforcing three controls as a single admission gate: image signature verification (Cosign), resource limits (mutate + validate), and the restricted Pod Security Standard. Every command below is real and runnable against any conformant cluster (AKS, EKS, GKE, or vanilla).
Prerequisites
- A Kubernetes cluster, v1.27+ (Kyverno 1.13 tracks recent APIs), with cluster-admin via
kubectl. helm3.12+,kubectl,cosign2.x, andjqon your workstation.- An OCI registry you control (GHCR, ECR, ACR, Artifact Registry). Examples use
ghcr.io/kloudvin. - A CI system that builds and signs images — examples use GitHub Actions; Jenkins works identically with the Cosign CLI.
- Cluster egress (or a mirror) to
ghcr.io/kyvernofor the controller images. - Optional but assumed in the operating model: HashiCorp Vault (or a KMS) holding the Cosign private key, Argo CD for GitOps delivery of policies, and Wiz / a SIEM consuming policy reports.
Target topology
Kyverno installs as a set of controllers in the kyverno namespace and registers two webhooks with the API server: a ValidatingWebhookConfiguration (deny on policy violation) and a MutatingWebhookConfiguration (inject defaults, verify-and-rewrite image digests). Every CREATE/UPDATE of a Pod-bearing resource flows API server → Kyverno admission controller → your ClusterPolicy rules → allow / mutate / deny. A separate reports controller writes PolicyReport objects continuously so you have a posture view even for resources admitted before a policy existed. Three independent control planes feed in:
- CI (GitHub Actions / Jenkins) builds the image and signs it with Cosign, whose private key is issued just-in-time from HashiCorp Vault (or keyless signing via OIDC) — so the signing secret never lives in a runner.
- GitOps (Argo CD) delivers the Kyverno policies themselves from a Git repo, making the policy set auditable and revertable, and Terraform/Ansible provisions the cluster add-on and namespace labels underneath.
- Posture/SOC: Wiz correlates the admission policies with cloud posture and flags drift, PolicyReports stream to Datadog/Dynatrace dashboards, and a hard denial can open a ServiceNow ticket. CrowdStrike Falcon stays on the nodes for runtime defense — Kyverno gates admission, Falcon watches what runs after.
1. Install Kyverno
Install via the official Helm chart. Run admission in high availability (3 replicas) for any cluster that matters — a single Kyverno pod is a single point of admission failure.
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno \
--namespace kyverno --create-namespace \
--version 3.3.4 \
--set admissionController.replicas=3 \
--set backgroundController.replicas=2 \
--set reportsController.replicas=2 \
--set cleanupController.replicas=2
Wait for the controllers and confirm the webhooks registered:
kubectl -n kyverno rollout status deploy/kyverno-admission-controller
kubectl get pods -n kyverno
kubectl get validatingwebhookconfigurations,mutatingwebhookconfigurations | grep kyverno
Notice you just installed four deployments, not one. Since Kyverno 1.10 the controllers are split so each responsibility can scale and fail independently — this matters the moment you care about performance or HA:
| Controller | Job | Why it is separate |
|---|---|---|
| admission | Answers the webhook in the hot path of every matching CREATE/UPDATE |
It is latency-critical and must be HA — this is the one that can block your API server |
| background | Re-scans existing resources for background: true policies, and runs generate/mutateExisting |
Runs off the hot path, on a schedule and via informers |
| reports | Aggregates rule results into PolicyReport / ClusterPolicyReport objects |
Report writing is chatty; isolating it keeps admission fast |
| cleanup | Runs CleanupPolicy TTL deletions of stale resources |
A janitor, unrelated to admission |
Only the admission controller sits in the request path, which is why you gave it the most replicas. The two webhook configurations you just confirmed are the only wiring between Kubernetes and Kyverno: the API server calls out to them for every object that matches, and everything Kyverno does — allow, mutate, deny, verify — happens inside that call. If you want the mental model of how that callout works, it is the standard validating & mutating admission webhook mechanism; Kyverno is a generic, policy-driven implementation of it.
A critical safety setting before you write any policy: decide what happens if Kyverno itself is down. The default failurePolicy: Fail means admission requests are rejected when the webhook is unreachable — safe, but it can wedge a cluster. Set it deliberately per policy (below). Also confirm Kyverno excludes its own and system namespaces so you cannot deadlock the control plane:
kubectl get configmap kyverno -n kyverno -o jsonpath='{.data.webhooks}' ; echo
# Expect kube-system / kyverno excluded by namespaceSelector
2. Set up Cosign signing in CI
Image-signature enforcement is worthless if your own images are unsigned, so build the signing side first. Generate a key pair, or — preferred — use keyless signing where Cosign gets a short-lived certificate from Fulcio bound to your CI’s OIDC identity, leaving no long-lived key to leak.
Before the commands, hold the mental model, because “signing an image” confuses almost everyone at first. Cosign does not modify your image. It computes a signature over the image’s content-addressable digest (sha256:...) and pushes that signature alongside the image in the same registry as a separate artifact, then (by default) records the event in Rekor, a public append-only transparency log. Verification later re-derives the digest, finds the signature artifact, and checks it against either a public key (keyed) or a certificate identity issued by Fulcio for your CI (keyless). Keyless is preferred precisely because there is no private key sitting anywhere to be stolen — the “key” is your pipeline’s OIDC identity, and it exists only for the seconds the signing runs.
Key-based, with the private key stored in HashiCorp Vault (never in the repo or a plain CI secret):
# One-time: generate and push the public half to the registry/Git; private half to Vault
cosign generate-key-pair
vault kv put secret/cosign/payments cosign.key=@cosign.key password='<passphrase>'
shred -u cosign.key # do not keep the private key on disk
The CI job pulls the key from Vault at build time and signs the digest (never a tag):
# .github/workflows/build-sign.yml (GitHub Actions)
permissions:
contents: read
id-token: write # required for keyless / Vault OIDC auth
jobs:
build-sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: docker/build-push-action@v6
id: build
with: { push: true, tags: "ghcr.io/kloudvin/api:${{ github.sha }}" }
- uses: sigstore/cosign-installer@v3
# Option A — keyless (recommended): identity is the GitHub OIDC token
- run: |
cosign sign --yes \
"ghcr.io/kloudvin/api@${{ steps.build.outputs.digest }}"
# Option B — key from Vault:
# - run: cosign sign --yes --key "hashivault://payments/cosign" \
# "ghcr.io/kloudvin/api@${{ steps.build.outputs.digest }}"
Signing the digest and not the tag is not a style choice — a tag is a mutable pointer, so a signature “on a tag” secures nothing once someone re-pushes it. Signing @sha256:... binds the signature to exact bytes.
Verify locally so you know the exact identity strings the cluster policy must match:
cosign verify \
--certificate-identity-regexp "https://github.com/kloudvin/.+" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/kloudvin/api@<digest> | jq '.[0].optional.Subject'
Copy those two strings down verbatim. The cluster policy in the next step must match the subject (the certificate identity, e.g. the workflow ref) and the issuer (https://token.actions.githubusercontent.com) exactly — a stray character in either silently fails every verification, and it is the single most common reason a working signing pipeline still gets its pods blocked.
3. Enforce image signatures with verifyImages
Now the gate. This ClusterPolicy uses Kyverno’s verifyImages rule to require a valid Cosign signature for any image from your registry. Start in Audit so you can see the blast radius before you block anything.
# policies/verify-images.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
annotations:
policies.kyverno.io/severity: high
spec:
validationFailureAction: Audit # flip to Enforce in step 7
failurePolicy: Fail
webhookTimeoutSeconds: 30 # signature checks are slower than plain validation
background: false # verifyImages cannot run as a background scan
rules:
- name: verify-ghcr-cosign-keyless
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "ghcr.io/kloudvin/*" # only OUR registry; pin public ones separately
failureAction: Audit
mutateDigest: true # rewrite the verified tag to an immutable @sha256
required: true
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/kloudvin/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
Read the rule the way Kyverno does. imageReferences scopes it to your registry only — a wildcard * here would try to verify every public sidecar and fail the lot, so keep third-party images to their own, separately-pinned policy. attestors is the list of parties whose signature you will accept, and count: 1 means “at least one of the entries must match.” A keyless entry says “accept a Fulcio certificate whose subject and issuer match these” — the two strings you copied in step 2. required: true (the default) means an image with no signature at all is a failure, not a skip.
Two per-rule fields carry the safety of this policy. failureAction: Audit is the modern, per-verifyImages control for what happens on a mismatch; it supersedes the spec-level validationFailureAction (which primarily governs validate rules and is being deprecated) — when both appear, prefer the per-rule one and keep them in step. And background: false is not optional here: signature verification has to reach a registry and Rekor, which the background scanner cannot do, so verifyImages runs only at admission.
If you signed with a Vault/KMS key instead of keyless, swap the attestor entry for the public key:
entries:
- keys:
publicKeys: |-
-----BEGIN PUBLIC KEY-----
MFkwEwYHKoZIzj0CAQ...your cosign.pub...
-----END PUBLIC KEY-----
rekor:
url: https://rekor.sigstore.dev
Apply it and watch the reports:
kubectl apply -f policies/verify-images.yaml
kubectl get clusterpolicy require-signed-images
kubectl get policyreport -A | head # PASS/FAIL counts per namespace
mutateDigest: true is doing quiet, important work: once verified, Kyverno rewrites :tag to the pinned @sha256:... digest in the pod spec, so what runs is provably the bytes you signed — closing the tag-mutation window where an attacker re-pushes a tag after verification.
4. Mutate in default resource limits
A pod with no limits can starve a node. Use a mutate rule to inject sane defaults when the author omits them — non-destructive, and far better adoption than rejecting every under-specified deployment on day one.
# policies/default-resources.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: add-default-resources
spec:
rules:
- name: set-default-requests-limits
match:
any:
- resources:
kinds: [Pod]
mutate:
foreach:
- list: "request.object.spec.containers"
patchStrategicMerge:
spec:
containers:
- name: "{{ element.name }}"
resources:
requests:
+(memory): "128Mi" # +(...) = add only if absent
+(cpu): "100m"
limits:
+(memory): "512Mi"
+(cpu): "500m"
Two pieces of Kyverno syntax do the work here. foreach with list: "request.object.spec.containers" walks each container in turn, exposing it as {{ element }} — that is how one rule patches every container by name rather than only the first. The +(...) anchor is the crucial one: +(memory) is an add-if-absent anchor, so Kyverno writes the default only when the field is missing and never clobbers a value the author set deliberately. (Kyverno has a small family of these anchors — () conditional, +() add-if-absent, X() “must not exist” — worth knowing when you read other people’s policies.)
5. Require resource limits with validate
Defaulting is a safety net, not a rule. Pair it with a validate rule so a container that explicitly omits limits in a namespace you care about is rejected outright — defence in depth against someone setting limits: null.
# policies/require-limits.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-resource-limits
spec:
validationFailureAction: Enforce
background: true
rules:
- name: require-cpu-mem-limits
match:
any:
- resources:
kinds: [Pod]
validate:
message: "CPU and memory limits are required on every container."
foreach:
- list: "request.object.spec.containers"
deny:
conditions:
any:
- key: "{{ element.resources.limits.memory || '' }}"
operator: Equals
value: ""
- key: "{{ element.resources.limits.cpu || '' }}"
operator: Equals
value: ""
The deny.conditions block is a small boolean expression: any means “deny if any of these is true.” The {{ element.resources.limits.memory || '' }} is JMESPath — Kyverno’s expression language — where || '' supplies an empty-string default if the field is missing, so a container with no memory limit evaluates to "" Equals "" → true → denied. Note this rule sets background: true (unlike verifyImages), which means the reports controller can also flag already-running pods that violate it, not just new admissions.
Order matters: Kyverno runs mutate rules before validate, so the step-4 defaults are applied first and only a container that cannot be defaulted (e.g. an explicit null) trips this deny.
6. Enforce restricted Pod Security
Replace the deprecated PodSecurityPolicy with Kyverno’s podSecurity subrule, which maps directly to the upstream Pod Security Standards. This single rule enforces the entire restricted profile — no root, no privilege escalation, dropped capabilities, seccomp, no host namespaces.
# policies/pod-security-restricted.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: pod-security-restricted
spec:
validationFailureAction: Enforce
background: true
rules:
- name: restricted-profile
match:
any:
- resources:
kinds: [Pod]
validate:
podSecurity:
level: restricted
version: latest
# Targeted, auditable exemptions instead of a blanket opt-out:
exclude:
- controlName: "Capabilities"
images: ["ghcr.io/kloudvin/net-tools:*"]
The Pod Security Standards define three cumulative levels — privileged (anything goes), baseline (blocks the well-known escapes), and restricted (hardened current best practice: runAsNonRoot, allowPrivilegeEscalation: false, drop: ["ALL"], a seccompProfile, no host namespaces). The podSecurity subrule enforces a whole level in one line rather than hand-writing a dozen deny conditions, and — unlike the built-in Pod Security Admission — its exclude lets you relax one named control for one set of images while every other control stays enforced. Here only the net-tools image may keep extra capabilities; it still may not run as root or escape its namespace.
Why Kyverno over the built-in Pod Security Admission: PSA only operates per-namespace at fixed levels and cannot make exceptions, mutate, or report centrally. Kyverno gives you per-image exemptions, the same PolicyReport stream as your other controls, and a single place security reviews. Apply all the policies through Argo CD rather than kubectl in production so the policy set is the Git-tracked source of truth:
kubectl apply -f policies/ # or sync the Argo CD Application
kubectl get cpol # all four ClusterPolicies, READY=true
7. Promote from Audit to Enforce
Never go straight to Enforce on a live cluster. Run in Audit, read the reports, fix the offenders, then flip. Find what would be blocked:
# Aggregate failing rules across the cluster
kubectl get policyreport -A -o json \
| jq -r '.items[].results[] | select(.result=="fail")
| "\(.policy)/\(.rule)\t\(.resources[0].namespace)/\(.resources[0].name)"' \
| sort | uniq -c | sort -rn
Once the failures are down to known exemptions, flip each policy to enforcing:
kubectl patch clusterpolicy require-signed-images \
--type merge -p '{"spec":{"validationFailureAction":"Enforce"}}'
# repeat for the verifyImages rule's own failureAction: Enforce
validationFailureAction (and its per-rule successor failureAction) is the single most important field for a safe rollout, and it has exactly two settings that matter: Audit records a result and admits the resource; Enforce rejects it. The discipline is always the same loop — apply in Audit, watch PolicyReport failures fall to a known set of intended exemptions, then patch to Enforce. There is no reason to ever start at Enforce, and every reason not to.
For high-risk control-plane namespaces, keep failurePolicy: Fail; for application namespaces during rollout, Ignore avoids an outage if Kyverno blips. Make that choice consciously, per policy.
Going deeper
You have four working policies. This section is for the engineer who has to run Kyverno in production — the webhook internals, the scale knobs, the verification depth, and the “which tool” decisions the happy path glosses over.
The admission-webhook architecture: failurePolicy, timeouts, and matchConditions
Everything Kyverno does happens inside a webhook callout from the API server, and three fields govern that callout’s behaviour under stress. failurePolicy decides what happens when Kyverno is unreachable (crashed, mid-rollout, network blip): Fail rejects the request (fail-closed, secure), Ignore admits it (fail-open, available). webhookTimeoutSeconds caps how long the API server waits for an answer — the default is 10 and the hard maximum is 30, which is exactly why the verifyImages policy in step 3 sets 30: a signature check must round-trip to a registry and Rekor, and a slow registry behind failurePolicy: Fail is an outage waiting to happen.
The dangerous interaction is Fail + an under-provisioned or matched-too-broadly webhook. If Kyverno’s own pods are being rescheduled and a fail-closed policy matches their namespace, the API server can refuse to admit the very pods that would bring Kyverno back — a self-inflicted deadlock. Kyverno ships with kube-system and kyverno excluded by namespaceSelector for this reason; never remove those exclusions. Beyond that, tighten what the webhook is even invoked for, so you are not paying admission latency on objects you do not police:
# Only invoke the webhook for pods that actually carry your app label
spec:
webhookConfiguration:
matchConditions:
- name: only-labelled
expression: "has(object.metadata.labels) && 'app' in object.metadata.labels"
matchConditions are CEL expressions evaluated by the API server before it ever calls Kyverno, so a non-matching object skips the network hop entirely — the cheapest possible way to shrink your admission blast radius and latency.
Background scans and PolicyReports
A validate policy with background: true is evaluated in two places: at admission (new/changed objects) and on a schedule by the reports controller, which re-scans everything already running. That second path is what gives you posture on workloads admitted before the policy existed. Results land in two CRDs from the open PolicyReport working group: PolicyReport (namespaced, one per namespace) and ClusterPolicyReport (cluster-scoped resources). They are your queryable source of truth:
# Every failing result, grouped, without opening a single manifest
kubectl get policyreport -A -o json \
| jq -r '.items[].results[] | select(.result=="fail") | .policy' \
| sort | uniq -c | sort -rn
The catch you already met in step 3: verifyImages and mutate rules cannot background-scan — signature checks need live registry access and mutation only makes sense at write time — so those policies run background: false and only ever see new admissions. Existing pods keep their unverified images until you re-roll them. Treat “policy applied” and “fleet compliant” as two different states, and roll deployments deliberately after enabling a verify policy.
Image verification in depth: keyed, keyless, and attestations
The attestors/entries structure is more expressive than the single-keyless example suggests. count: N under an attestor set means “at least N of these entries must match,” so you can require, say, two independent signatures — one from CI, one from a release approver — before an image is admitted (dual control for production). Each entry is either keys (a static public key, from Vault/KMS) or keyless (a Fulcio certificate identity), and both can pin rekor.url so verification also insists the signing event was logged in the transparency log.
Signatures prove who built it; attestations prove facts about it. Cosign can attach signed predicates — an SBOM, SLSA provenance, a passing vulnerability scan — and Kyverno can require them under verifyImages[].attestations, checking the predicate content with conditions:
verifyImages:
- imageReferences: ["ghcr.io/kloudvin/*"]
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/kloudvin/*"
issuer: "https://token.actions.githubusercontent.com"
attestations:
- type: https://slsa.dev/provenance/v1
attestors:
- count: 1
entries:
- keyless:
subject: "https://github.com/kloudvin/*"
issuer: "https://token.actions.githubusercontent.com"
conditions:
- all:
- key: "{{ buildDefinition.externalParameters.workflow.ref || '' }}"
operator: Equals
value: "refs/heads/main"
That policy admits an image only if it carries SLSA provenance, signed by your CI, asserting it was built from main — supply-chain enforcement well past “is it signed.”
Mutate and generate beyond defaults — and synchronize
mutate is not just for resource defaults: common production uses include injecting imagePullSecrets, adding standardised labels/annotations for cost allocation, or setting automountServiceAccountToken: false by default. mutateExisting can even patch resources already in the cluster when a trigger fires. generate goes further — it creates new objects in response to another object appearing, the classic case being “every new namespace gets a default-deny NetworkPolicy and a copy of the registry pull-secret.” The field that makes generate trustworthy is synchronize: true: with it, Kyverno’s background controller reconciles the generated object, restoring it if someone deletes or edits it, and cleaning it up if the source policy or trigger goes away. clone copies from a live source object (right for distributing a shared secret/CA bundle), while data embeds the resource inline. These mutate/generate patterns are a lesson in themselves; treat this as the pointer and go deep separately.
CEL vs Kyverno syntax (and ValidatingAdmissionPolicy)
There are now three ways to express admission policy on a modern cluster, and they are not competitors so much as a spectrum:
- Native
ValidatingAdmissionPolicy(VAP) — built into Kubernetes (GA in 1.30), written in CEL, evaluated inside the API server with no webhook and therefore no extra network hop orfailurePolicyrisk. Powerful for pure validation, but it cannot mutate, generate, verify signatures, or write PolicyReports. - Kyverno’s YAML DSL — the
validate/mutate/generate/verifyImagesyou have been writing: JMESPath expressions, anchors,foreach. Approachable and far more capable (mutation, generation, image verification, reporting). - Kyverno’s
validate.celsubrule — you can write CEL inside a Kyverno policy when an expression is cleaner in CEL, and Kyverno can even auto-generate native ValidatingAdmissionPolicies from suitable Kyverno policies, so simple validations run in-process on the API server while Kyverno keeps the rich features. The practical rule: reach for VAP/CEL when a check is pure, hot-path validation and you want to shed the webhook; stay in Kyverno’s DSL the moment you need mutate, generate, verifyImages, or central reports.
Kyverno vs Gatekeeper vs Pod Security Admission — when each
| Pod Security Admission | OPA Gatekeeper | Kyverno | |
|---|---|---|---|
| Language | none (namespace labels) | Rego | Kubernetes YAML (+ optional CEL) |
| Scope | Pod security only | anything (general policy) | anything (general policy) |
| Mutate / generate | no | mutation (limited); no generate | yes, both |
| Image signature verify | no | via external data / not native | native verifyImages |
| Exceptions | no (namespace level only) | selectors / referential | exclude + PolicyException CRD |
| Reporting | audit annotations only | constraint status / audit | PolicyReport / ClusterPolicyReport |
| Learning curve | trivial | steep (Rego) | moderate (YAML) |
The decision rule: PSA is the floor — free, built-in, dependency-free, so keep it enforcing baseline/restricted at the namespace level as a backstop that survives a Kyverno outage (see Pod Security Admission migration). Reach for Kyverno when you need mutation, generation, image verification, per-image exceptions, or central reporting — which is most real programs. Choose Gatekeeper if your team already lives in Rego or shares OPA policy across non-Kubernetes systems; its expressiveness is unmatched but the Rego learning curve is real. Running PSA plus one policy engine is not redundant — PSA is the guarantee, the engine is the expressive layer on top.
Policy exceptions instead of weakening the policy
When one workload legitimately cannot satisfy a rule, do not loosen the rule for everyone. Kyverno’s PolicyException is a separate, RBAC-controllable object that exempts a named resource from a named rule, leaving the policy itself strict and the exception greppable and auditable:
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: allow-legacy-caps
namespace: legacy-app
spec:
exceptions:
- policyName: pod-security-restricted
ruleNames: ["restricted-profile"]
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["legacy-app"]
names: ["legacy-migration-*"]
This is far better than editing pod-security-restricted to add a namespace to its exclude: the exception is a distinct object you can put behind its own RBAC, expiry review, and Git history, so “who is exempt from what, and who approved it” is a query, not archaeology. Exceptions are enabled by default in recent Kyverno; on locked-down installs confirm the admission controller’s --enablePolicyException flag (and the namespace it watches) is set.
Performance and HA
The admission controller is in the synchronous path of every matching request, so its health is your API server’s health for those objects. The production posture: run 3+ admission replicas with a PodDisruptionBudget so a node drain never leaves the webhook unbacked; keep failurePolicy: Fail for security-critical policies but earn the right to by ensuring the webhook is genuinely never down; and shrink the matched set with tight match blocks and matchConditions so you are not evaluating policy on irrelevant objects. Watch admission latency (Kyverno exports Prometheus metrics) — a verify policy’s registry/Rekor round-trip is the usual culprit, mitigated with a generous webhookTimeoutSeconds and an in-cluster registry mirror. The background and reports controllers use leader election, so their extra replicas are for failover, not throughput. One more autogen subtlety worth knowing: when you match on kinds: [Pod], Kyverno automatically synthesises equivalent rules for the pod controllers (Deployment, StatefulSet, DaemonSet, Job, CronJob) so authors get the error at kubectl apply on the Deployment, not later on the Pod — controlled by the pod-policies.kyverno.io/autogen-controllers annotation if you ever need to narrow it.
Validation
Prove the gate works with a deliberately bad pod — every one of these must be rejected once policies are enforcing:
# 1. Unsigned / wrong-registry image -> blocked by verifyImages
kubectl run bad-unsigned --image=nginx:latest
# Error: ... require-signed-images: image is not signed
# 2. Signed image with NO limits -> defaulted by mutate, or denied if null
kubectl run noreq --image=ghcr.io/kloudvin/api@<digest> --dry-run=server -o yaml \
| grep -A4 resources # see injected requests/limits
# 3. Root / privileged pod -> blocked by restricted profile
kubectl run rooty --image=ghcr.io/kloudvin/api@<digest> \
--privileged --dry-run=server
# Error: ... pod-security-restricted: privileged containers are not allowed
# 4. A correctly signed, limited, non-root pod -> ADMITTED
kubectl apply -f tests/good-pod.yaml # should succeed
Confirm the digest rewrite actually happened on the admitted pod:
kubectl get pod good-pod -o jsonpath='{.spec.containers[0].image}'; echo
# Expect ghcr.io/kloudvin/api@sha256:... (a digest, not a tag)
Run Kyverno’s own test harness in CI so policy changes are unit-tested before they ship via Argo CD:
kyverno test ./policies/ # asserts expected pass/fail per fixture
Rollback / teardown
Policies are declarative, so rollback is fast — switch back to Audit first if a policy is over-blocking in production, then remove if needed:
# Soft rollback: stop denying, keep reporting
for p in require-signed-images require-resource-limits pod-security-restricted; do
kubectl patch cpol "$p" --type merge -p '{"spec":{"validationFailureAction":"Audit"}}'
done
# Remove a single policy
kubectl delete clusterpolicy pod-security-restricted
# Full uninstall (also removes both webhooks, so admission stops gating)
helm uninstall kyverno -n kyverno
kubectl delete ns kyverno
If you delivered policies via Argo CD, do the rollback in Git (revert the commit) and let the sync remove them — never kubectl delete out of band, or Argo will flag drift and may re-create them.
Practice challenges
Work these top to bottom — they escalate from “read the report” to “design the rollout.” Try each before opening the solution.
1. Beginner — see what would be blocked without blocking anything. You just applied require-resource-limits in Audit. Write the one pipeline that lists which policy/rule is failing and how many resources trip it, using only PolicyReports.
<details> <summary>Solution</summary>
kubectl get policyreport -A -o json \
| jq -r '.items[].results[] | select(.result=="fail") | .policy + "/" + .rule' \
| sort | uniq -c | sort -rn
Why: Audit mode admits everything but records each result in a PolicyReport; the reports are the blast-radius preview you read before flipping to Enforce. No cluster change, full visibility.
</details>
2. Beginner — why is my “Enforce” policy letting everything through? A colleague swears their validate policy is enforcing, yet bad pods still get in. Name the two most likely one-line causes and the field to check.
<details> <summary>Solution</summary>
Either the policy is still validationFailureAction: Audit (records but never rejects), or it uses the newer per-rule form and the rule’s validate.failureAction is Audit while the spec-level field looks like Enforce. Check the effective action: kubectl get cpol <name> -o yaml | grep -i -E 'failureAction|validationFailureAction'. Why: Audit vs Enforce is the behavioural switch; a policy can be perfectly correct and simply not be denying because it was never promoted.
</details>
3. Intermediate — default a label without overwriting it. Write a mutate rule that adds team: unassigned to a Pod’s labels only if no team label exists, leaving any author-set value untouched.
<details> <summary>Solution</summary>
mutate:
patchStrategicMerge:
metadata:
labels:
+(team): "unassigned"
Why: the +(...) add-if-absent anchor is the whole trick — +(team) writes the default only when the key is missing, so a pod that already declares team: payments is left alone. Overwriting deliberate values is the classic mutate footgun.
</details>
4. Intermediate — pin the two identity strings. Your keyless verify policy blocks a correctly-signed image. Which two keyless fields must match the Cosign certificate exactly, and what command produces their expected values?
<details> <summary>Solution</summary>
subject (the certificate identity, e.g. https://github.com/kloudvin/...) and issuer (https://token.actions.githubusercontent.com). Get the truth from the image itself: cosign verify --certificate-identity-regexp ... --certificate-oidc-issuer ... <image@digest> | jq '.[0].optional'. Why: keyless verification is an exact string match on the Fulcio cert; a stray branch in the subject or a wrong issuer URL silently fails every verify, and it is the most common “signing works but pods are blocked” cause.
</details>
5. Advanced — exempt one workload without weakening the policy. A legacy-migration-* Deployment in namespace legacy-app genuinely needs an extra Linux capability and cannot pass restricted. Grant it an exception without editing pod-security-restricted or relaxing it for anyone else.
<details> <summary>Solution</summary>
apiVersion: kyverno.io/v2
kind: PolicyException
metadata:
name: allow-legacy-caps
namespace: legacy-app
spec:
exceptions:
- policyName: pod-security-restricted
ruleNames: ["restricted-profile"]
match:
any:
- resources:
kinds: ["Pod"]
namespaces: ["legacy-app"]
names: ["legacy-migration-*"]
Why: a PolicyException is a separate, RBAC-controllable, Git-tracked object scoped to named resources, so the strict policy stays strict for everyone else and “who is exempt and who approved it” is a query — far better than adding a namespace to the policy’s exclude.
</details>
6. Advanced — design the safe rollout and pick failurePolicy. You are enabling require-signed-images (verifyImages) across 140 namespaces. Give the ordered rollout and justify the failurePolicy choice so a registry blip cannot wedge the fleet.
<details> <summary>Solution</summary>
- Apply with
failureAction: Auditandbackground: false; collect PolicyReport fails for a full sprint. 2. Fix/exempt offenders (re-roll deployments so existing pods are re-verified — verifyImages does not background-scan). 3. Promote toEnforcenamespace by namespace, not fleet-wide in one patch. 4. KeepfailurePolicy: Failfor the guarantee, but earn it: 3+ admission replicas, aPodDisruptionBudget, a generouswebhookTimeoutSeconds(30) and an in-cluster registry mirror so a slow registry never combines with fail-closed into an outage. Why: the real-world failure is a registry hiccup plus fail-closed plus a broad match rejecting every deploy at once — including your own fix; a staged, well-provisioned rollout removes each ingredient. </details>
Common beginner mistakes
These are misconceptions — the wrong mental model, not just a wrong command. Fix the model and the commands follow.
- “I’ll set the policy to Enforce and see what breaks.” Enforce on a live cluster is the breakage — it rejects real deploys immediately. Right model: Audit first, always. The
PolicyReportshows you exactly what would fail; you promote to Enforce only once that list is down to intended exemptions. - “
failurePolicy: Failis the secure choice, so use it everywhere.” Fail-closed is secure until Kyverno is briefly unreachable — then it can block new pods, including Kyverno’s own during a cold start, and wedge the cluster. Right model:Failis correct for security-critical policies once you have made the webhook genuinely highly available (replicas + PDB) and excludedkube-system/kyverno; useIgnoreon app namespaces during rollout. - “verifyImages will catch the unsigned pods already running.” It will not — signature rules run only at admission (
background: false), never as a background scan. Right model: existing pods keep their unverified images until their next rollout; re-roll deployments after enabling the policy and treat “applied” and “compliant” as different states. - “My
cosign verifypasses, so the cluster policy will too.” Only if the policy’ssubject/issuer(keyless) orpublicKeys(keyed) match exactly what signed the image. Right model: a wrong issuer URL, a stray branch in the subject regex, or the wrong public key fails silently on every image — confirm withcosign verifyand copy the strings verbatim into the policy. - “
match: kinds: [Pod]only checks bare Pods, so my Deployments are unguarded.” Kyverno’s autogen automatically synthesises equivalent rules for pod controllers, so the check fires on Deployments/StatefulSets/Jobs too — and the author sees the error atkubectl applyon the Deployment. Right model: matching Pod usually covers its controllers; do not hand-duplicate rules per kind, but do remember init/ephemeral containers if you hand-write aforeachoverspec.containers. - “A broad
matchis fine, more coverage is better.” Matching every namespace drags system and Kyverno’s own workloads into evaluation, adding latency and deadlock risk. Right model: match the narrowest set that meets the requirement, exclude control-plane namespaces, and usematchConditions(CEL, evaluated by the API server) to skip the webhook entirely for objects you do not police. - “Kyverno replaces Pod Security Admission.” They layer. Right model: keep PSA enforcing
baseline/restrictedas a free, dependency-free floor that survives a Kyverno outage, and use Kyverno for the expressive controls (image verify, per-image exceptions, mutation, reports) PSA cannot do.
Common pitfalls
- Cluster wedge from
failurePolicy: Fail. If Kyverno is unreachable and a policy fails closed, new pods (including Kyverno’s own on a cold start) can be blocked. Always excludekube-systemandkyverno, and keep app namespaces onIgnoreduring rollout. - verifyImages can’t background-scan. Signature rules only run at admission (
background: false); existing pods are not retro-verified. Re-roll deployments after enabling, or pods admitted earlier keep running unsigned images. - Identity string mismatch. Keyless
subject/issuermust match the certificate exactly — a wrong issuer URL or a stray branch in the subject regex silently fails every verify. Confirm withcosign verifyfirst (step 2). - Forgetting to pin digests. Without
mutateDigest: true, an attacker re-pushing a verified tag bypasses the check. Always rewrite to a digest. - Mutate vs validate ordering surprises. Defaults from a mutate rule appear before your validate rule runs; test the combined result with
--dry-run=server, not the policies in isolation. - initContainers and ephemeralContainers. A
foreachoverspec.containersmisses init/ephemeral containers — add them explicitly or attackers hide privileged work there.
Security notes
This is a Zero-Trust admission control: the cluster trusts no image it cannot cryptographically tie to your CI identity, runs nothing as root, and pins every workload to a signed digest. Keep the Cosign private key in HashiCorp Vault or use keyless signing so there is no long-lived secret to steal; rotate the key and update the policy’s public key together. Feed every PolicyReport to Wiz (to correlate admission posture with cloud misconfig and attack paths) and your SIEM, and let a hard denial open a ServiceNow incident so security gets a ticket, not just a log line. Remember the boundary: Kyverno gates admission — CrowdStrike Falcon on the nodes covers runtime (a compromise after a pod is admitted), and the two together close the gap.
Cost notes
Kyverno’s own footprint is small — the HA controllers run comfortably in roughly 0.5 vCPU / 512Mi per replica, a rounding error against the workloads they protect. The real saving is indirect: the step-4/5 resource-limit policies stop unbounded pods from triggering node autoscale events and cluster overprovisioning, which is usually a far larger line item than the controller. Watch one operational cost — verifyImages adds a Rekor/registry round-trip per new image, so size webhookTimeoutSeconds (step 3) generously and run an in-cluster registry mirror if your image pull volume is high, both to cut latency and to avoid public-registry rate limits.
Glossary
- Admission controller — code that intercepts requests to the Kubernetes API server after authentication/authorization but before the object is persisted, and can allow, modify, or reject them. Kyverno is a policy-driven admission controller.
- ValidatingWebhookConfiguration / MutatingWebhookConfiguration — the two Kubernetes objects that tell the API server to call out to an external webhook (here, Kyverno) to validate (allow/deny) or mutate (modify) matching requests.
ClusterPolicy/Policy— Kyverno’s policy objects.ClusterPolicyis cluster-scoped;Policyis namespaced. Both holdruleswritten as ordinary YAML.validate/mutate/generate/verifyImages— Kyverno’s four rule types: reject non-compliant objects, modify objects in-flight, create new objects in response to others, and verify image signatures/attestations.validationFailureAction/failureAction— what a policy does on a violation:Audit(record, admit) orEnforce(reject).failureActionis the newer per-rule form; the spec-levelvalidationFailureActionis being deprecated.failurePolicy— what the API server does when Kyverno is unreachable:Fail(reject the request, fail-closed) orIgnore(admit it, fail-open). Distinct fromfailureAction, which is about violations.webhookTimeoutSeconds— how long the API server waits for Kyverno to answer (default 10, max 30). Sized up forverifyImagesbecause signature checks reach a registry and Rekor.- Background scan — the reports controller re-evaluating already-running resources against
background: truepolicies on a schedule, so you see violations on pre-existing workloads (not available forverifyImages/mutate). PolicyReport/ClusterPolicyReport— CRDs holding per-resource rule results (pass/fail/skip); the namespaced and cluster-scoped posture views you read before promoting a policy to Enforce.- Cosign — the Sigstore tool that signs (and verifies) container images, attaching the signature to the image digest in the registry and recording it in Rekor.
- Keyless signing — Cosign signing with a short-lived Fulcio certificate bound to your CI’s OIDC identity, so there is no long-lived private key to steal; the “key” is the pipeline identity.
- Fulcio / Rekor — Sigstore’s certificate authority (issues the short-lived signing cert) and its public append-only transparency log (records signing events for later verification).
- Attestor / attestation — an attestor is a party whose signature you accept (a public key or a keyless identity); an attestation is a signed statement about an image (SBOM, SLSA provenance, scan result) that Kyverno can also require and inspect.
mutateDigest— theverifyImagesoption that rewrites a verified:tagto its immutable@sha256:...digest in the pod spec, so what runs is provably the signed bytes.- Digest — the content-addressable
sha256:...identity of an image; immutable, unlike a tag. Signatures and pins target the digest. - Pod Security Standards (PSS) — the upstream
privileged/baseline/restrictedsecurity profiles for pods.restrictedrequires non-root, no privilege escalation, dropped capabilities, seccomp, and no host namespaces. podSecuritysubrule — Kyverno’svalidate.podSecuritythat enforces a whole PSS level in one rule, with per-control, per-imageexcludeexemptions.- Pod Security Admission (PSA) — the built-in, namespace-label-driven enforcement of the Pod Security Standards; simpler than Kyverno but pod-security-only, with no exceptions, mutation, or central reports.
PolicyException— a separate Kyverno object (kyverno.io/v2) that exempts named resources from a named policy/rule, keeping the policy itself strict and the exemption auditable.- Gatekeeper (OPA) — the other major policy engine, written in the Rego language via ConstraintTemplates and Constraints; more expressive, steeper learning curve, no native image verification.
- CEL /
ValidatingAdmissionPolicy(VAP) — Common Expression Language and the built-in Kubernetes policy type (GA in 1.30) that runs CEL validations inside the API server with no webhook; Kyverno can use CEL and even generate VAPs. - Autogen — Kyverno automatically synthesising equivalent rules for pod controllers (Deployment, StatefulSet, Job, …) when you match
kinds: [Pod], so authors get the error at the controller, not just the Pod. - Anchor
+(...)— a Kyverno mutation anchor meaning “add this field only if it is absent”; the safe way to inject defaults without overwriting author-set values. foreach— a Kyverno construct that iterates a list (e.g.spec.containers), exposing each item as{{ element }}, so one rule applies to every container.- JMESPath — the query/expression language Kyverno uses inside
{{ ... }}, e.g.{{ element.resources.limits.cpu || '' }}to read a field with an empty-string default.