Table of contents
The auditor is not asking for your tool inventory. They ask one question, and they expect an answer with a verb in it: what concretely stops an unsigned image from starting on this cluster? You open the Kubernetes documentation looking for the built-in mechanism that answers. There isn’t one.
In Kubernetes 1.35 as in 1.36, no built-in mechanism verifies an image signature. The admission controller designed to delegate that decision to a third party, ImagePolicyWebhook, is disabled by default. The modern declarative policy mechanism, ValidatingAdmissionPolicy, has been stable since v1.30 but runs inside the API server. This article takes the problem in that order: what each building block actually does according to its own documentation, what it does not prove — Sigstore says so itself — and the rollout order that yields the most defensible refusals for the fewest components to operate. No lab was run: everything below rests on a published page, or is flagged as unverified.
TL;DR
- The decision. Of the five possible enforcement points, only one verifies a signature, and it costs an out-of-tree component to operate. The other four refuse a registry, a digest or a pull — useful, but not the same sentence in an audit report.
- The trade-off that costs. Keyless mode removes key management by replacing it with three network dependencies at verification time — Fulcio, Rekor, the certificate transparency log — plus an OIDC provider at signing time. “Keyless” does not mean “no infrastructure”.
- The Monday-morning action. Write a
ValidatingAdmissionPolicyinadmissionregistration.k8s.io/v1, stable since v1.30, requiring a single registry and a digest-pinned image. Zero extra components — and it is the silent prerequisite of every later signature policy.
Version contract
This series states explicitly what it is talking about, because an API claim without a version number becomes false a few months later.
- Floor: Kubernetes 1.35. 1.34 leaves support on 27 October 2026, before this article is published.
- Target: Kubernetes 1.36. 1.36.2 is the latest upstream release published; on the platform side, VKS has shipped
v1.36.1+vmware.4-vkr.5since 18 June 2026. - Not covered: 1.37, absent from the upstream releases pages as of 15 August 2026. It is not anticipated here.
- Removed mechanism, named exactly once:
PodSecurityPolicy, deprecated in v1.21 and removed in v1.25. It will not appear again in this article, and never in the present tense.
Here is the published state of every mechanism cited, with the question that concerns us in the last column.
| Mechanism | apiVersion / nature | Published state | Version | Verifies a signature? |
|---|---|---|---|---|
ValidatingAdmissionPolicy |
admissionregistration.k8s.io/v1 |
stable | v1.30 | no — evaluates CEL inside the API server |
ValidatingAdmissionPolicyBinding |
admissionregistration.k8s.io/v1 |
stable | v1.30 | — |
MutatingAdmissionPolicy |
admissionregistration.k8s.io/v1 |
stable, enabled by default | v1.36 | no |
ValidatingAdmissionWebhook |
admission controller | enabled by default | 1.36 | yes, by delegation |
ImagePolicyWebhook |
admission controller, API imagepolicy.k8s.io/v1alpha1 |
disabled by default | 1.36 | by delegation, if you write the backend |
ClusterImagePolicy (Sigstore) |
policy.sigstore.dev/v1beta1 |
component “still actively under development!” | out of tree | yes |
ClusterPolicy / verifyImages (Kyverno) |
kyverno.io/v1 |
documentation displayed at v1.18.0 | out of tree | yes |
| Gatekeeper shipped by VKS | OPA Gatekeeper CRDs | Standard Package 3.22.2+vmware.1-vks.1 |
out of tree | no — source registry, not signature |
Two upstream discrepancies deserve to be named rather than smoothed over. The reference page for MutatingAdmissionPolicy carries the stable, enabled-by-default state at v1.36, yet the list of controllers enabled by default in 1.36, published on another page loaded the same day, does not name it. Likewise, the upstream source file for admission controllers still describes ValidatingAdmissionPolicy as gated behind a feature gate and a v1alpha1 group, while its reference page carries v1.30 stable in v1. Two files, two states: this article keeps the reference page’s state and flags the gap, without adjudicating.
Four words that everyday vocabulary merges
This is the table that prevents the false compliance line. No upstream page publishes it; it is this article’s own assembly.
| Object | What it contains | What it proves | What it does not prove | Standard |
|---|---|---|---|---|
| SBOM | the component inventory of an artefact | that a producer declares this composition | neither that the declaration is accurate, nor that the components are sound | SPDX (ISO/IEC 5962:2021), CycloneDX 1.7 (ECMA-424) |
| Signature | a signed digest, a certificate, a log entry | who signed, and when | neither that the signer should have signed, nor that the artefact is sound | Cosign, Fulcio, Rekor |
| Attestation | a signed predicate, wrapped in DSSE | that a claim is signed by an identity | nothing beyond what the predicate claims | in-toto |
| Provenance | an attestation describing the build | how the artefact was built, and at which level of guarantee | neither the contents, nor the absence of vulnerabilities | SLSA v1.2, Build L0 to L3 |
An audit table that files “SBOM” and “signature” under the same “integrity” column is wrong. One is a declaration of content, the other a declaration of identity. You can sign a lying SBOM, and the signature will be perfectly well-formed.
On provenance levels, the SLSA v1.2 specification — status “Approved”, with v1.1 marked “Retired” — reads as a scale of resistance to forgery, not as a quality score. Build L0: “No guarantees”. Build L1: provenance exists, but it is “trivial to bypass or forge”. Build L2: forging it “requires an explicit ‘attack’”. Build L3: it “requires exploiting a vulnerability beyond most adversaries’ capabilities”. Nowhere is sound software promised.
On inventory formats, one precision is required because the SPDX site contradicts itself: its Specifications page displays 3.0 under the heading “Current Version”, while its home page announces that 3.1 is available as a first release candidate. Both pages were loaded on the same day. The honest phrasing cites both, and does not attach the ISO/IEC 5962:2021 number to a specification version number — the site does not do so either. CycloneDX, for its part, is at 1.7 since 21 October 2025, standardised as ECMA-424 on 10 December 2025.
Sigstore: what “keyless” actually costs
Three components, and one sentence that decides everything. Cosign is the client: it creates the signing material and requests a certificate. Fulcio is the certificate authority, described as “a free code signing Certificate Authority, built to make short-lived certificates available to anyone”, issuing X.509 certificates valid for 10 minutes. Rekor is the transparency log, “an immutable, append-only ledger”, whose entries “are never mutated or removed”.
In keyless mode, identity comes from an OIDC token: “The OIDC token contains a user’s email address or service account, or workflow information in the case of CI signing.” Since the certificate expires in ten minutes, proof of freshness no longer comes from it but from the signed timestamp of the log entry. And that is where the bill arrives, written in black and white by the project itself: “Verifying keyless signatures require verifying signatures from Rekor, material (SCTs) from the CT log, and certificates that chain up to Fulcio.”
The dependency lands at verification time, not just at signing time
Keyless mode does not remove infrastructure, it moves it to someone else. Three distinct services must be reachable on every verification: the Rekor log, the certificate transparency log, and the Fulcio chain. The escape hatch exists and is documented — stand up your own stack with scaffolding, build your own TUF repository, or assemble the trust material with cosign trusted-root create and pass it through --trusted-root, using the SIGSTORE_REKOR_PUBLIC_KEY, SIGSTORE_ROOT_FILE and SIGSTORE_CT_LOG_PUBLIC_KEY_FILE variables. This is an architecture decision, and it is taken before the first signature.
One gap stated out loud, because it weighs on that decision: no availability commitment could be found for the public Sigstore instance. The Public Deployment page explicitly documents the absence of guarantees for the staging environment — “neither SLO guarantees nor the same protection of the root key material” — but the section covering the public instance rendered empty on load. Neither “the public instance is reliable” nor the opposite can be written here. The question, however, belongs before you build a production chain on top of it.
Finally, the most tempting shortcut, and the project itself refuses it. Its Threat Model page writes: “not everything that’s signed is secure”. A valid keyless signature guarantees that a signer authenticated to that identity provider at that time. It does not guarantee that they should have been able to authenticate — “in the event of a compromised account, or compromised identity provider” — nor that they should have signed the given message, nor “that the software artifact in question is ‘good’”. Any sentence of the form “Sigstore protects against such-and-such attack class” goes beyond what the documentation allows anyone to say.
Five enforcement points, and what each one refuses
| Enforcement point | Where it runs | Source registry | Digest | Signature | Attestation | Cost |
|---|---|---|---|---|---|---|
ValidatingAdmissionPolicy (CEL) |
API server, in-process | yes | yes | no | no | no component |
| Gatekeeper — VKS image registry policy | webhook installed by the platform | yes, that is its purpose | not published | no | no | maintained by VKS |
| Sigstore Policy Controller | out-of-tree webhook | yes, via glob |
via tag resolution | yes | yes | one component, “actively under development” |
Kyverno — verifyImages |
out-of-tree webhook | yes, via imageReferences |
yes, mutateDigest by default |
yes | yes | one component |
| Harbor — content trust | outside the cluster, at pull | per project | no | yes | not published | shipped as a Standard Package |
Why does CEL not conclude on a signature? This is a piece of reasoning, not a quotation, and it belongs to this article. What the upstream pages write is that “CEL expressions are evaluated directly in the API server, making CEL a convenient alternative to out-of-process mechanisms, such as webhooks”, under a published execution cost budget. But verifying a keyless signature requires reaching Rekor, the certificate transparency log and Fulcio. A mechanism that never leaves the API server therefore cannot conclude. The phrase “without relying on any external HTTP callouts”, widely attributed to the documentation, was not found in the upstream source files that were loaded: it is not quoted here, and the reasoning is presented as reasoning.
The built-in mechanism designed for this use case does exist, though. The upstream source file writes three things about ImagePolicyWebhook: “Type: Validating.”, “allows a backend webhook to make admission decisions.”, and “This admission controller is disabled by default.” Its configuration exposes kubeConfigFile, allowTTL, denyTTL, retryBackoff and defaultAllow, and its exchange API is still at imagepolicy.k8s.io/v1alpha1, ten minor versions after our floor. The sentence “You must implement a webhook and deploy it separately”, widely copied around, does not appear in the loaded file either — the three sentences above say the same thing, and they are checkable.
That leaves the Harbor row, which is not on the same plane as the others. The Harbor 2.14.0 documentation describes the procedure — the project’s Configuration tab, the Cosign or Notation checkbox, Save — and publishes the exact effect: “Harbor will only allow verified images to be pulled from the project.” Pulled. Not deployed. A pod whose image is already in the node’s cache never goes back to the registry. Worth noting: the commercial vendor post Securing Your Software Supply Chain with Harbor, signed by Dhruv Tyagi and published on 30 January 2026 on blogs.vmware.com, writes instead that only signed images “can be deployed”. The gap between those two verbs is exactly the subject of this section: cite both, bound both, judge neither.
The rollout order, phase by phase
The order proposed here is not the tutorial order, which starts with signing. It is ranked by the ratio between refusals obtained and components to operate, so that an audit answer exists in the first week.
Phase 0 — refuse without installing anything. Two rules can be written with ValidatingAdmissionPolicy alone: the source registry and digest pinning. Neither verifies a signature, but pinning is the silent prerequisite of everything else — a signature covers a digest, never a tag.
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicy
metadata:
name: images-registry-and-digest.k8s-sec.corp.example
spec:
failurePolicy: Fail
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
validations:
- expression: >-
object.spec.containers.all(c,
c.image.startsWith('harbor.corp.example/webshop/'))
message: "image outside the allowed registry harbor.corp.example/webshop/"
# string pattern, NOT a digest: no sha256 value is written here
- expression: >-
object.spec.containers.all(c, c.image.contains('@sha256:'))
message: "image not pinned by digest"
apiVersion: admissionregistration.k8s.io/v1
kind: ValidatingAdmissionPolicyBinding
metadata:
name: images-registry-and-digest-webshop
spec:
policyName: images-registry-and-digest.k8s-sec.corp.example
validationActions: ["Deny"] # Deny and Warn cannot be combined — published constraint
matchResources:
namespaceSelector:
matchLabels:
supply-chain.corp.example/enforce: "true"
Three caveats come with that example, and they are part of the content: it only covers spec.containers, whereas a real policy must also cover spec.initContainers and spec.ephemeralContainers; it targets pods, so a non-compliant Deployment is accepted and its pods rejected; and these two CEL expressions were never executed.
Phase 1 — sign in CI, and settle the trust mode. With a key: cosign attest --predicate <file> --key cosign.key <image>, checked with cosign verify-attestation --key cosign.pub <image>, with a full key lifecycle on the team’s shoulders. Without a key: the runner’s OIDC token becomes the identity, and the dependency described above settles in on both ends of the chain. Instance values remain deliberate blanks — REPLACE_WITH_OIDC_ISSUER, REPLACE_WITH_OIDC_IDENTITY, REPLACE_WITH_REKOR_URL — and no key, no identity and no example digest will appear in this article: a sample value in a signing procedure ends up pasted into a real policy the next day.
Phase 2 — verify at admission, in warn mode first. The Sigstore Policy Controller installs through Helm into a cosign-system namespace and only validates namespaces that have opted in, via the policy.sigstore.dev/include: "true" label. It “resolves the image tags to ensure the image being ran is not different from when it was admitted”. Its decisive field is published: “enforce - Reject (default) / warn - allow but warn”.
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: webshop-signed-images
spec:
mode: warn # enforce = reject (default); switch after a survey
images:
- glob: "harbor.corp.example/webshop/**"
authorities:
- name: ci-keyless
keyless:
url: "https://fulcio.sigstore.dev"
identities:
- issuer: REPLACE_WITH_OIDC_ISSUER
subject: REPLACE_WITH_OIDC_IDENTITY
Two citation caveats, to carry rather than hide. First, the official sample policies published by Sigstore carry apiVersion: policy.sigstore.dev/v1alpha1, while the overview page on the same site gives ClusterImagePolicy as v1beta1 — two pages of the same site, loaded the same day. This article keeps v1beta1 for its manifests and flags the gap, without adjudicating. Second, the API reference publishes a maturity caveat on the policy language: “only rego or cue are supported. Furthermore, only cue is tested”. CUE is recommended here because the project says so, not out of preference.
The Kyverno alternative covers the same need with different attributes: imageReferences, required, mutateDigest — on by default, it adds the digest when it is missing — and an attestors block. The internal structure of that block could not be captured on the page as loaded: it stays a declared gap, and the placeholder will not be filled by analogy with the Sigstore syntax, since the two projects do not share a schema.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-signature-webshop
spec:
rules:
- name: verify-webshop
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "harbor.corp.example/webshop/*"
mutateDigest: true # published default
required: true
attestors: REPLACE_WITH_ATTESTOR_BLOCK # exact schema not captured
A webhook inherits the upstream good practices: short timeout, limited scope, filtering through matchConditions, highly available deployment, and above all “prevent your webhook from triggering itself”. Upstream adds a sobriety note: “The Kubernetes project recommends that you use CEL-based admission control when possible.” For signatures, that is not possible — so the webhook is not a convenience choice, it is the documented price of cryptographic verification, with a failure mode CEL does not have.
Phase 3 — attest, last. Attestation comes at the end because it is the most expensive to produce, the most expensive to verify, and the least binding: a policy requiring an SBOM to be present requires nothing about its contents. The --type flag of cosign attest accepts nine named types — slsaprovenance, slsaprovenance02, slsaprovenance1, link, spdx, spdxjson, cyclonedx, vuln, openvex, custom — or a URI, with custom as the default. And the same documentation warns: “Note that there are also SBOM predicate types, but they are not recommended”, because the whole SBOM then ends up embedded in the signature bundle, to be downloaded again on every verification. An article recommending --type spdxjson without that sentence contradicts its own source.
Phase 4 — close the loop at the registry, without merging the planes. Harbor content trust is a second refusal, not the same one: it acts before the node obtains the image, while admission acts before the object exists. Both are documented separately in the audit answer, each with its exact verb.
What VKS already installs — and what it does not verify
The platform anchor deserves precise wording, because both opposite shortcuts are wrong. VKS does install an admission engine: “When you implement one of these policies, VKS cluster management installs Gatekeeper on the cluster and maintains that installation”. The published policy types include “security policy, image registry policy, and mutation policy”. And what the image policy does, exactly, is define “the registries from which an image can be pulled”, with organisation → project → cluster inheritance and no restriction by default.
Neither signature, nor digest, nor tag is named on those pages. So the exact formulation is: VKS provides the engine and the registry-provenance policy; cryptographic verification remains to be added. A quieter, more telling detail: the VKS private-registry procedures explicitly use imgpkg copy ... --cosign-signatures, which shows the platform preserves the Cosign signatures of its own packages on copy — without any loaded page documenting a mechanism that would then verify them at admission. Worth noting, without drawing a conclusion from it: another published copy procedure does not carry that flag.
Harbor is available on both sides — as Standard Package 2.14.3+vmware.2-vks.1, and as a Supervisor Service, with the convenience that clusters automatically trust its certificates. Neither of those two pages names Cosign, Notation or content trust: the content trust procedure cited above comes from the Harbor documentation, not from the platform documentation. That is a stated gap, not an oversight.
Pitfalls
Going straight to enforce, on a webhook with failurePolicy Fail
This is the outage scenario of this article. The warn mode exists and is published — “warn - allow but warn”. On a real cluster, the first signature policy surfaces infrastructure images nobody had counted. The right sequence is: Deny from the start for phase 0, which adds no component and no failure mode; warn for the cryptographic verification phases, then a switch to enforce in a separate change, after surveying which images would have been refused. The length of that observation window is not quantified on any loaded page: do not announce it as a number of days.
- Confusing pulled and admitted. Answering “we enabled Harbor content trust” to the question “what stops an unsigned pod from starting” is a wrong answer, and the Harbor documentation hands you the exact verb that reveals it.
- Writing a signature policy before pinning digests. As long as deployment uses tags, the policy verifies the signature of whatever the registry chooses to resolve at pull time. It is also why Kyverno turns
mutateDigeston by default and why the Policy Controller resolves tags. - Forgetting
initContainersandephemeralContainers. The most frequent defect of this type of rule, and it is invisible in a trial run because you try with a simple pod. - Reproducing the scope limit already met in article 1. A policy scoped to
resources: ["pods"]accepts theDeploymentand rejects its pods: the team sees a “successful” rollout with zero running pods. Different mechanism, identical reading error. - Stacking two engines without knowing which refuses first. A registry policy inherited from the platform on one side, a CEL policy in the cluster on the other: pick which one is authoritative, and write it down.
- Assuming the Policy Controller’s coverage. The Kubernetes resource types it actually validates — pods only, or workload objects — are published neither on the documentation pages nor on the repository that were loaded. Survey that on your own platform before depending on it.
Conclusion
The defensible audit answer is not “we use Sigstore”. It is one sentence per mechanism, with its verb and its limit: this registry is the only allowed one, this image is pinned by digest, this signature is verified at admission by this component, and that refusal applies at pull and not at startup. Each fits on a line, each rests on a published page — and it is precisely because none claims to guarantee more than it can that they hold up in front of an auditor.
Nothing is built in
Kubernetes 1.35 and 1.36 verify no image signature. The one controller designed to delegate the decision is disabled by default, and its exchange API is still at v1alpha1.
Start with the digest
Single registry and digest pinning in a ValidatingAdmissionPolicy, stable since v1.30: zero components, one defensible refusal, and the prerequisite for everything that follows.
A signature says who, not what
“not everything that’s signed is secure” — the project writes it itself. Provenance makes an alert interpretable; it does not replace it.
This article closes the question “what is allowed to run”. It says nothing about “what actually happened”: a binary with perfectly established provenance can do, at runtime, exactly what an anonymous binary would have done. That is the subject of the final article in the series.
In the series, and around it
Series Kubernetes security in production: Kubernetes RBAC: the foundations, and the four pitfalls that survive an audit · Network policies and Cilium: building a defensible default-deny · this article · Runtime security: Falco and Tetragon, and how to actually choose.
Neighbouring reads: GitOps on VKS: multi-tenant Argo CD bootstrap for the delivery chain everything above hangs on, First VKS cluster on VCF 9 for the platform context, and VCF 9.1: security and resilience for the infrastructure plane.
Get the next one by email
New articles and series, sent when they are published. No other mail.



