Skip to content
Edouard Topin's Blog
Kubernetes security in production / Series 03/04

Supply chain security: Sigstore, SBOM, admission control

Kubernetes verifies no image signature on its own. Signing with Sigstore, inventorying with an SBOM, refusing at admission — and what each of those verbs actually covers.

Edouard Topin
17 min read
Abstract editorial illustration of a signed parcel with a traceable chain of geometric links.

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.

Kubernetes 1.35 → 1.36Sigstore · SBOM · admissionNo lab was run

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 ValidatingAdmissionPolicy in admissionregistration.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.5 since 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.”

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

  • 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 mutateDigest on by default and why the Policy Controller resolves tags.
  • Forgetting initContainers and ephemeralContainers. 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 the Deployment and 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.

Get the next one by email

New articles and series, sent when they are published. No other mail.

One click to unsubscribe, any time.

Back to blog
Share

Related articles

  1. 23 min read

    Network policies and Cilium: building a defensible default-deny

    The NetworkPolicy API ships with Kubernetes; enforcing it is the CNI's job. What Cilium adds, what stays standard, and how to reach default-deny by watching real flows before blocking any.

  2. 18 min read

    Kubernetes RBAC: the foundations, and the pitfalls that survive an audit

    Every one of these pitfalls is published on kubernetes.io. What is missing is the ordering — and the path that leads from a vSphere Namespace straight to cluster-admin.

  3. 17 min read

    Runtime security: Falco and Tetragon, and how to actually choose

    Falco and Tetragon both collect through eBPF. What separates them lies elsewhere: event scope, rule model, and above all what each one can actually prevent.

Follow along

New articles, thoughts, and updates.