Admission-Time Image Verification

Admission-time image verification is the enforcement point where a Kubernetes cluster refuses to run a container image unless that image carries a cryptographic proof — a signature, and often a provenance/attestation — that satisfies a written policy. It is the last preventive gate before runtime: everything upstream (signing at build, generating provenance, scanning for CVEs) produces evidence; this gate is where a machine finally checks that evidence and either admits or denies the pod. The check runs inside the Kubernetes admission chain as a webhook, so it fires on every Pod-creating request before any container is pulled or started. The animating rule is a short sentence with heavy consequences: “only signed images, from a trusted builder, meeting a required SLSA level, may run here.” The tools that implement it — Sigstore’s policy-controller, Kyverno’s verifyImages, Connaisseur, and OPA Gatekeeper — differ in surface but share one mechanism: intercept the request, resolve the image to an immutable digest, verify the attached cryptographic material against configured trust roots, and turn the result into an admit/deny decision.

This note owns the supply-chain practice of verifying images at the admission gate. It deliberately does not re-teach the Kubernetes admission machinery — how a ValidatingWebhookConfiguration is wired, how the API server calls out to a webhook, the mutating-vs-validating ordering — that mechanism lives in Kubernetes MOC (Admission Controllers, ValidatingAdmissionWebhook, MutatingAdmissionWebhook). Nor does it re-teach how signatures and provenance are produced; that is Sigstore Signing and Keyless Verification, Build Provenance and Verifiable Builds, and The SLSA Framework. Here the job is: what does the gate check, how does it check it, and what does a real policy look like.

Mental Model — the gate that consumes the proof

Think of the whole supply chain as a relay of claims and this gate as the referee who finally validates them. A build pipeline signs the image and attaches a provenance attestation (claims: “I, GitHub Actions workflow X, built this from source repo Y at commit Z”). Those claims travel with the image in the registry as extra OCI artifacts. When someone applies a Deployment, the admission gate is the first component with both the authority to say no and the policy that says what “good” means.

flowchart LR
    subgraph Build["Upstream (produces proof)"]
        SIGN["cosign sign<br/>+ attest provenance"]
    end
    subgraph Reg["Registry"]
        IMG["image@sha256:...<br/>+ .sig + .att artifacts"]
    end
    subgraph Cluster["Kubernetes API server"]
        MUT["Mutating webhook<br/>resolve tag → digest"]
        VAL["Validating webhook<br/>verify sig + attestation<br/>vs ClusterImagePolicy"]
    end
    SIGN --> IMG
    IMG --> MUT --> VAL
    VAL -->|"proof valid"| ADMIT["Pod admitted<br/>(pinned to digest)"]
    VAL -->|"missing / bad proof"| DENY["Pod denied<br/>(or warn)"]

What it shows and the insight to take: the gate does not trust the image because it came from your registry — a registry is just storage an attacker can push to. It trusts the image because the cryptographic material verifies against an identity you configured. The registry is where the proof is stored; the admission gate is where the proof is believed or rejected. Note the two-phase shape: a mutating pass rewrites the mutable tag (nginx:1.27) to an immutable digest (nginx@sha256:...) so the thing verified is the exact thing that runs, and a validating pass makes the admit/deny call. This ordering matters — verifying a tag would be meaningless, because the tag can be re-pointed at a different image between check and pull (a time-of-check-to-time-of-use gap).

Why the digest pin is load-bearing

The single most important property of admission-time verification is that it ends on a digest, not a tag. A tag is a mutable pointer; myapp:latest today and myapp:latest tomorrow can be different bytes. A signature is made over a specific digest. So a naive check — “does myapp:latest have a valid signature?” — is exploitable: an attacker who can re-point the tag serves a signed digest to the verifier and an unsigned (or differently-signed) digest to the kubelet’s image pull. Every serious verifier closes this gap the same way: it resolves the tag to a digest, verifies the signature for that digest, and mutates the pod spec to reference the digest directly, so the kubelet pulls exactly what was verified.

  • Sigstore policy-controller “automatically resolves image tags to their digests, ensuring the running image matches what was admitted” (policy-controller overview).
  • Kyverno’s mutateDigest field (default true) “mutates matching images to add the image digest,” and its verifyDigest field (default true) additionally requires that images already use digests; the verifyImages rule runs “during the mutation webhook first, then during validation” (Kyverno verifyImages).
  • Connaisseur translates tags to their SHA-256 digests so that only “trusted digests (signed by a trusted entity) are passed to the container runtime” (Connaisseur docs).

This is why image verification is inherently both a mutating and a validating admission operation, and why you cannot implement it correctly with a pure validating policy that only reads the incoming spec.

Mechanical walk-through — a pod create, step by step

The sequence below traces a kubectl apply of a Deployment through the gate using the Sigstore policy-controller as the concrete example; Kyverno and Connaisseur differ in field names but follow the identical shape.

sequenceDiagram
    participant U as kubectl / CI
    participant API as kube-apiserver
    participant MW as policy-controller<br/>(mutating)
    participant Reg as OCI Registry
    participant Rekor as Fulcio / Rekor
    participant VW as policy-controller<br/>(validating)
    U->>API: create Pod (image: app:v1)
    API->>MW: AdmissionReview (mutating)
    MW->>Reg: resolve app:v1 → app@sha256:abc
    MW-->>API: patch: image = app@sha256:abc
    API->>VW: AdmissionReview (validating)
    VW->>Reg: fetch signature + attestation for sha256:abc
    VW->>Rekor: verify cert chain (Fulcio root)<br/>+ inclusion proof (Rekor)
    VW->>VW: match identity (issuer/subject)<br/>+ evaluate CUE/Rego on predicate
    alt all authorities satisfied
        VW-->>API: allowed = true
        API-->>U: Deployment created
    else proof missing or identity mismatch
        VW-->>API: allowed = false, message
        API-->>U: admission denied
    end

What it shows and the insight to take: verification is not one comparison but a chain of them. First the image identity is pinned (digest). Then the signature is checked against a trust root — for keyless Sigstore that means validating the short-lived signing certificate against the Fulcio certificate-authority root and confirming the signature is recorded in the Rekor transparency log. Then the signer identity is matched — the certificate must have been issued to the OIDC identity you expect (e.g. issuer https://token.actions.githubusercontent.com, subject your build workflow). Only then, optionally, is the content of an attestation evaluated with a policy language. A failure at any link denies the pod. The takeaway: “is it signed?” is the weakest possible question; “is it signed by the identity I trust, and does its provenance say what I require?” is the real one.

The AND/OR trust algebra

A subtle but critical semantic: within one policy, multiple authorities combine with OR (any one satisfying authority admits), but across multiple policies that match the same image, the combination is AND (every matching policy must pass) (policy-controller overview). This lets you express “signed by our CI or by our break-glass key” (two authorities, OR) while also layering an independent org-wide policy that every image must additionally satisfy (AND). Getting this backwards is a common misconfiguration that either over-blocks legitimate images or silently admits ones a second policy was meant to catch.

Configuration — three real policies

Sigstore policy-controller — ClusterImagePolicy

The policy-controller is installed with a namespace opt-in: only namespaces labeled policy.sigstore.dev/include=true are subject to verification by default (policy-controller overview). A ClusterImagePolicy then declares which images and which authorities:

apiVersion: policy.sigstore.dev/v1alpha1
kind: ClusterImagePolicy
metadata:
  name: require-ci-signature
spec:
  images:
    - glob: "ghcr.io/acme/**"        # which images this policy governs
  authorities:                        # authorities OR together
    - keyless:
        url: https://fulcio.sigstore.dev   # Fulcio CA that issued the signing cert
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subject: https://github.com/acme/app/.github/workflows/release.yml@refs/heads/main
      ctlog:
        url: https://rekor.sigstore.dev     # transparency-log inclusion required
      attestations:                          # optionally require + inspect provenance
        - name: must-have-slsa
          predicateType: https://slsa.dev/provenance/v1
          policy:
            type: cue
            data: |
              predicate: builder: id: "https://github.com/acme/ci"
  mode: enforce                        # or "warn" to alert without blocking

Line-by-line: images.glob selects the images (glob, not regex); authorities is the OR-set of acceptable proofs; keyless.identities pins the OIDC issuer and certificate subject so only your build workflow counts as a valid signer; ctlog.url forces a Rekor transparency-log inclusion check; the attestations block requires a SLSA provenance predicate to be present and runs an inline CUE policy asserting the builder ID; mode: enforce denies on failure (set warn to admit-but-alert). Cluster-wide default behavior for images matched by no policy is set separately via the config-policy-controller ConfigMap’s no-match-policy, which takes deny, warn, or allow (policy-controller overview). Air-gapped or custom Sigstore deployments are handled by a TrustRoot CRD (remote or serialized TUF root, or bring-your-own keys).

Kyverno — verifyImages with keyless cosign

Kyverno folds verification into its general policy engine. A keyless verifyImages rule:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-keyless-images
spec:
  validationFailureAction: Enforce      # Enforce = deny; Audit = report only
  rules:
    - name: verify-signature
      match:
        any:
          - resources: {kinds: [Pod]}
      verifyImages:
        - imageReferences:
            - "ghcr.io/acme/*"           # static strings only — NO {{ }} interpolation
          mutateDigest: true              # tag → digest (default true)
          verifyDigest: true              # require digest form (default true)
          required: true                  # every matching image must be verified
          attestors:
            - entries:
                - keyless:
                    issuer: https://token.actions.githubusercontent.com
                    subject: https://github.com/acme/app/.github/workflows/build.yaml@refs/heads/main
                    rekor:
                      url: https://rekor.sigstore.dev

And a companion rule that verifies a SLSA provenance attestation, checking a field inside the predicate:

      verifyImages:
        - imageReferences: ["ghcr.io/acme/*"]
          type: SigstoreBundle
          attestations:
            - predicateType: https://slsa.dev/provenance/v1
              attestors:
                - entries:
                    - keyless:
                        issuer: https://token.actions.githubusercontent.com
                        subject: https://github.com/acme/app/.github/workflows/build.yaml@refs/heads/main
                        rekor: {url: https://rekor.sigstore.dev}
              conditions:
                - all:
                    - key: "{{ buildDefinition.buildType }}"
                      operator: Equals
                      value: https://actions.github.io/buildtypes/workflow/v1

Two Kyverno gotchas worth internalizing: imageReferences does not support variable interpolation (only static globs) (Kyverno verifyImages); and Kyverno keeps a TTL cache of verification results (imageVerifyCacheEnabled, default max-size 1000, default TTL 60 minutes), so a policy change or a re-push may not re-verify until the cache entry expires — a real source of “why is the old image still admitted” confusion.

Connaisseur — validator-per-signing-scheme

Connaisseur is purpose-built for signature verification and nothing else. It runs as a mutating webhook, supports three validator families — Notary v1 / Docker Content Trust, Sigstore/Cosign, and Notation (Notary v2) — and maps images to validators via an image policy; on a request it will deny (no trusted digest), modify (resolve tag→digest), or admit, and offers a detection mode that warns instead of blocking (Connaisseur docs). It is maintained by Secure Systems Engineering GmbH and supports Kubernetes v1.16+.

From “signed” to “meets a SLSA level”

The strongest form of this gate does not just verify a signature — it enforces that the image’s provenance attestation proves a required build integrity level. The building block is cosign verify-attestation, which checks a DSSE-enveloped in-toto attestation and can run a CUE or Rego policy over the predicate (cosign attestation verification):

cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity=https://github.com/acme/app/.github/workflows/build.yaml@refs/heads/main \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  --policy slsa-l3.rego \
  ghcr.io/acme/app@sha256:abc...

The admission tools embed exactly this check. To assert a SLSA Build track level you write policy that inspects the provenance predicate for the properties that level demands — that the build ran on a trusted hosted builder (L2+), that the builder ID matches, that the source URI and revision are the ones you expect. SLSA v1.2 defines a Build track from L0 to L3 and, as of v1.2, reintroduces a Source track; the exact obligations belong to The SLSA Framework, but the enforcement mechanism is this: at admission, require the attestation to exist, verify it was signed by your builder’s identity, and assert the predicate fields the level requires.

Uncertain

Verify: the release/stability status and API version of each tool as of 2026-07-25 — the Sigstore policy-controller docs still carry the banner “This component is still actively under development!” and its CRD is served at policy.sigstore.dev/v1alpha1/v1beta1 depending on release; Kyverno’s verifyImages field names (e.g. SigstoreBundle type, mutateDigest default) track the current 1.x line but the exact minor version was not pinned here. Reason: fetched docs are “latest” and did not enumerate a version. To resolve: check the policy-controller and Kyverno release notes and CRD apiVersion for the version you deploy. #uncertain

Failure modes and common misunderstandings

  • Verifying the tag, not the digest. Covered above — the whole point is the digest pin. A policy that admits based on a tag’s signature but does not mutate to the digest is exploitable via tag re-pointing.
  • Fail-open on webhook outage. If the admission webhook’s failurePolicy is Ignore, an attacker who can DoS or delete the webhook makes all images admit unverified. The Kubernetes MOC admission notes cover failurePolicy; the supply-chain lesson is that a security gate configured fail-open is not a gate. But fail-closed (Fail) risks a cluster-wide outage if the verifier is down — a real availability/security trade-off.
  • The gate covers only what it can see. Init containers, ephemeral containers, and images referenced indirectly must all be matched by imageReferences/glob, or they slip through. System and infrastructure images (CNI, CSI, the verifier itself) frequently lack signatures and need explicit static/pass authorities or namespace exclusions — Sigstore provides “static authorities” precisely for this (policy-controller overview).
  • Identity glob too broad. A subject: "*" or issuer left unpinned reduces the check to “is it signed by anyone Sigstore will issue a cert to” — which is nearly everyone. The security is entirely in pinning issuer and subject to your build identity.
  • Cache staleness (Kyverno). A revoked or newly-required policy may not take effect until the TTL cache expires.
  • Admission is not runtime. The gate checks the pod at create time. It does not re-verify a running pod, and it does not stop a compromise that happens after start — that is Falco’s job (runtime detection, mechanism in Linux Security MOC). Admission verification is preventive; it is one layer, paired with detective controls.

Choosing a tool

ToolScopeSigning schemesPolicy languageBest when
Sigstore policy-controllerImage verification (purpose-built)Cosign keyless/key, staticCUE or Rego on attestationsAll-in on Sigstore; want native ClusterImagePolicy + TrustRoot
KyvernoGeneral policy engine incl. verifyImagesCosign, NotaryYAML conditions + CUE/RegoAlready run Kyverno for other policy; want one engine
ConnaisseurImage verification (purpose-built)Notary v1/DCT, Cosign, NotationImage-policy matchingNeed Notary/DCT or Notation, not just Sigstore
OPA GatekeeperGeneral validating admissionVia external data / Ratify pluginRego (ConstraintTemplates)Standardized on Gatekeeper/Rego; verification via external provider

The honest trade-off: if you have standardized on one admission-policy engine (Kyverno or OPA Gatekeeper) for pod-security and other rules, adding image verification there avoids running a second controller. If image verification is your primary need and you are all-in on Sigstore, the purpose-built policy-controller is the most direct fit. OPA Gatekeeper verifies images only via an external-data provider (e.g. Ratify) rather than natively, so it is the least self-contained of the four for this specific task.

Production notes

Rollout discipline is what separates a working gate from a self-inflicted outage. The universal pattern is to deploy in warn/audit mode first (mode: warn for policy-controller, validationFailureAction: Audit for Kyverno, detection mode for Connaisseur), watch which real workloads would be denied, add exclusions/static authorities for legitimately-unsigned infrastructure images, and only then flip to enforce. Scope with namespace labels so the blast radius of a bad policy is one namespace, not the cluster. Pin identities tightly (issuer and subject), require Rekor inclusion so a stolen ephemeral key alone is insufficient, and layer an org-wide AND policy that no image can escape. Treat the verifier’s own availability as a reliability concern: a fail-closed gate is a hard dependency in every pod-create path, so it needs the same SLO attention as any other critical control-plane component (Site Reliability Engineering MOC).

See Also