Sign and verify images with Cosign and Kyverno

Keyless image signing with cosign in CI and signature verification at the admission controller, so unsigned or tampered images simply stop scheduling.

Feb 24, 2026·Updated ·6 min readAdvanced·By SecOpsLog · documentation-verified

A scan proves an image was clean when CI looked at it. It cannot prove that the image the kubelet pulled tonight is the one CI looked at, because a tag is a pointer and anyone with push access to the registry can move it. A signature closes that gap: CI signs the image digest with an identity the cluster has been told to trust, and admission refuses any Pod whose image lacks a valid signature from that identity. The tag stops mattering; the digest and the signer are what admission checks.

Sign the digest in CI, verify it at admission

The signature is attached to the digest in the registry and recorded in Rekor. Admission asks two questions of it: is it valid, and was it made by the identity this policy names; the right-hand card is what fails one of them. Simplified: Fulcio’s certificate issuance and the Rekor inclusion proof are one arrow here.

Cosign keyless signing and admission: the release workflow signs the image digest with a Fulcio certificate bound to its OIDC identity, the registry stores the signature next to the digest and Rekor records it, and Kyverno verifyImages at admission resolves the tag to a digest, checks the signature against the pinned issuer and subject, and admits the Pod with the digest written in; unsigned, retagged or differently-signed images are rejected release.yml jobOIDC token, Fulcio certcosign sign @sha256:9f2aRegistryimage by digestsignature + cert; RekorAdmissionKyverno verifyImagesissuer + subjectAdmitted: the release image1resolve the tag to its digest2fetch signature for that digest3cert issuer = GitHub Actions4subject = release.yml @ v* tag5admit, image rewritten to @sha256Rejected before the Pod existsunsignedby hand: no signatureretaggedtag moved to a digestwith no signaturewrong joba fork or other workflow:subject fails the regexpSignatures are on the digest, so a moved tag proves nothing; admission pins the Pod to that digest.A signature says who built it; whether it is safe to run is the scanner’s question, same pipeline.

Keyless: what is actually signed, and by what

With keyless signing there is no long-lived private key to store, rotate or leak. Cosign generates an ephemeral key pair for the run, presents the CI job's OIDC token to Fulcio, and receives a short-lived certificate that binds that key to the token's identity (for GitHub Actions, the workflow file and ref that ran). The signature, the certificate and the public key go to the registry next to the image, and the signing event is recorded in Rekor, a transparency log anyone can query. When the certificate expires minutes later nothing breaks, because verification checks that the signature was made while it was valid, using Rekor's timestamped record.

.github/workflows/release.yml (sign step)
permissions:
id-token: write # lets the job obtain the OIDC token Fulcio exchanges
contents: read
packages: write
steps:
- uses: sigstore/cosign-installer@v3
- name: Push, then sign the digest
run: |
docker push "$REGISTRY/$IMAGE:$TAG"
DIGEST=$(crane digest "$REGISTRY/$IMAGE:$TAG")
cosign sign --yes "$REGISTRY/$IMAGE@$DIGEST" # --yes: accept the Rekor upload prompt
bash — what signing and verification print
cosign sign --yes registry.acme.dev/shop/api@sha256:9f2a1c…
Generating ephemeral keys...
Retrieving signed certificate...
tlog entry created with index: 748392017
Pushing signature to: registry.acme.dev/shop/api
cosign verify registry.acme.dev/shop/api@sha256:9f2a1c… \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity-regexp "^https://github.com/acme/shop/.github/workflows/release.yml@refs/tags/v"
Verification for registry.acme.dev/shop/api@sha256:9f2a1c… --
- The cosign claims were validated
- Existence of the claims in the transparency log was verified offline
- The code-signing certificate was verified using trusted certificate authority certificates

The two verify flags are not optional and they are the security decision. --certificate-oidc-issuer pins which identity provider issued the token, and --certificate-identity (or its -regexp form) pins who within that provider: for GitHub Actions the identity is the workflow file URL with its ref, so the pattern above accepts only the release.yml workflow in one repository running on a version tag. A verifier that accepts any identity from the GitHub issuer accepts an image signed by any public repository's workflow, which is a signature check that proves nothing.

The same two questions, asked by admission

Kyverno's verifyImages rule performs the verification inside the admission webhook: for every Pod whose image matches imageReferences, it resolves the digest, fetches the signature, checks it against the keyless attestor's issuer and subject, and rejects the request if nothing matches. Two details matter beyond the policy text. Kyverno also mutates the Pod to use the verified digest instead of the tag, so a later retag cannot swap the image behind a running Deployment. And the rule's failureAction decides whether a failure blocks the request (Enforce) or only produces a policy report (Audit).

verify-images.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-shop-images
spec:
webhookConfiguration:
timeoutSeconds: 30
rules:
- name: require-release-signature
match:
any:
- resources:
kinds: [Pod]
namespaces: [shop]
verifyImages:
- imageReferences: ["registry.acme.dev/shop/*"]
failureAction: Audit # switch to Enforce after the report is clean
attestors:
- entries:
- keyless:
issuer: https://token.actions.githubusercontent.com
subjectRegExp: ^https://github\.com/acme/shop/\.github/workflows/release\.yml@refs/tags/v.*$
rekor:
url: https://rekor.sigstore.dev
bash — an unsigned image, after the switch to Enforce
kubectl run demo -n shop --image=registry.acme.dev/shop/api:hotfix-by-hand
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
resource Pod/shop/demo was blocked due to the following policies
verify-shop-images:
require-release-signature: 'failed to verify image registry.acme.dev/shop/api:hotfix-by-hand: .attestors[0].entries[0].keyless: no signatures found'
the image was pushed by a person, not by release.yml: exactly the case the policy exists for

Audit first, then the exceptions, then Enforce

Start with failureAction: Audit on one namespace and read the policy reports for a week. Every entry is either an image that should have been signed and was not (fix the pipeline) or a platform image the policy caught by accident. The second group is the reason to scope imageReferences to your own registry paths: an ingress controller, a CNI or a monitoring agent pulled from a vendor's registry has no signature from your workflow, and a policy that matches * blocks the next cluster upgrade. Give platform images their own rule with the vendor's attestor, or leave them out of this policy and cover them with a separate allowlist that someone owns.

A signature proves origin, not safety
A signed image is one your release workflow built. It can still contain a critical CVE, a hard-coded credential or a backdoored dependency; the scanner in the same pipeline exists to find those. Signing tells the cluster who built the image; scanning tells you what is in it; neither replaces the other.

The identity that signs here is the same one the deploy job uses to reach the cloud, so OIDC federation and signing are usually set up together. The same cosign attest flow attaches an SBOM or a provenance statement to the digest, and the same Kyverno rule can require those with attestations, which moves the claim from "we built this" to "we built this from that commit with that workflow"; SBOM attestations picks up there.

Go deeper in a courseSoftware supply chain securitySigning, SBOMs, provenance and SLSA levels from commit to cluster.View course

Related posts

Quick reference