BlogKubernetes

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·4 min readAdvanced·By the SecOpsLog team · command-tested

Scanning tells you an image was clean when it left CI. It says nothing about whether the image running in your cluster is the one CI actually built. Signing closes that gap: Cosign signs the digest in the pipeline, Kyverno verifies the signature at admission, and an unsigned or tampered image simply never schedules. That is the difference between trusting a tag and trusting a cryptographic chain from build to kubelet.

Modern Cosign is keyless — there is no private key to store or rotate. It exchanges your CI's OIDC token for a short-lived certificate from Fulcio and records the signature in the Rekor transparency log. This walkthrough signs by digest in GitHub Actions, verifies with Kyverno, and shows what happens when someone deploys an unsigned image. For the full supply-chain sequence, see Software supply chain security.

Sign in CI, verify on admit

Unsigned images should never become Pods. Digest, not tag.

CI build cosign sign Registry image + signature Admission cosign verify 1 Sign at build keyless OIDC or keypair attach to digest, not tag provenance travels with image 2 Store signatures OCI referrers / .sig same registry as the image immutable by digest 3 Verify on admit Kyverno / Gatekeeper / policy unsigned → reject Pod before it ever schedules Signing without admission is theater. Admission without signing has nothing to check. build → registry → verify → Pod create
Sign — CIStore — registryVerify — admission
Signature lifecycle from build to admission

Sign the digest, never a tag. Tags are mutable; a signature on `:latest` guarantees nothing.

1CI builds imagepush to registry2Resolve digestimmutable reference3Cosign signkeyless via OIDC4Rekor logspublic transparency5Pod createdadmission webhook6Kyverno verifyissuer + subject match7Schedule or denyEnforce, not Audit
bash — cosign keylesslive
cosign sign --yes registry.io/myapp@sha256:9f2a1c...
Generating ephemeral keys ...
Retrieving signed certificate from Fulcio ...
Requesting OIDC token (github-actions) ...
tlog entry created: rekor.sigstore.dev index 74839201
 
cosign verify --certificate-oidc-issuer=github registry.io/myapp@sha256:9f2a1c...
Verified OK — cert identity ci@myorg, issuer GitHub Actions

Sign in the pipeline

Sign the digest, never a tag. Cosign reads the CI's OIDC identity automatically when COSIGN_EXPERIMENTAL is enabled, so there are no secrets in this step — the trust anchor is your identity provider, not a key file in the repo. Grant the workflow id-token: write so GitHub can mint the token Fulcio expects.

.github/workflows/release.yml
permissions:
id-token: write
contents: read
- name: Sign image (keyless)
env:
COSIGN_EXPERIMENTAL: "1"
run: |
DIGEST=$(crane digest "$REGISTRY/$IMAGE:$TAG")
cosign sign --yes "$REGISTRY/$IMAGE@$DIGEST"

Verify at admission with Kyverno

A ClusterPolicy with verifyImages intercepts every pod, checks the signature against the expected OIDC identity, and rejects anything that does not match. Set validationFailureAction: Enforce — in Audit it only logs, and logging is not a control when an attacker can still schedule. Pin the issuer URL and subject pattern to your org's CI identity.

verify-images.yaml
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-images
spec:
validationFailureAction: Enforce
rules:
- name: require-signature
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences: ["registry.io/*"]
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/myorg/*"

Deploy an image CI never signed and admission stops it cold — the pod object is never created. That failure mode is what you want: tampering at the registry or a developer pulling an unvetted image from Docker Hub should never reach a running pod. Test the deny path in staging before you flip Enforce in production, and keep Audit logs for a week so you know what would have broken.

Roll out gradually: start with validationFailureAction: Audit on one namespace, confirm only expected unsigned images appear in policy reports, then switch to Enforce cluster-wide. Pair verifyImages with image digest pinning in Deployments so :latest cannot bypass the check by mutating behind your back.

bash — unsigned deploylive
kubectl run demo --image=registry.io/unsigned:latest
kyverno admission webhook: verify-images
 
Error: admission webhook denied the request:
verify-images: failed to verify image registry.io/unsigned:latest:
no matching signatures found
 
images signed by CI schedule normally
Signatures prove origin, not safety
A signed image is one you built — not one free of CVEs. Keep Trivy scanning in the pipeline; signing and scanning are complementary gates, not substitutes. Also carve a narrow exception path for platform images (CNI, ingress) with a documented allowlist — otherwise every infra upgrade becomes an emergency policy change.

Where this goes next

The same keyless flow signs attestations — attach an SBOM or a SLSA provenance statement and verify those at admission too. That is the jump from 'this image is ours' to 'this image was built from this commit by this pipeline'. The Software supply chain security course covers signing, SBOMs, provenance, and SLSA from commit to cluster.

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

Related posts