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.
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.
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.
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.
permissions:id-token: write # lets the job obtain the OIDC token Fulcio exchangescontents: readpackages: writesteps:- uses: sigstore/cosign-installer@v3- name: Push, then sign the digestrun: |docker push "$REGISTRY/$IMAGE:$TAG"DIGEST=$(crane digest "$REGISTRY/$IMAGE:$TAG")cosign sign --yes "$REGISTRY/$IMAGE@$DIGEST" # --yes: accept the Rekor upload prompt
cosign sign --yes registry.acme.dev/shop/api@sha256:9f2a1c…Generating ephemeral keys...Retrieving signed certificate...tlog entry created with index: 748392017Pushing signature to: registry.acme.dev/shop/apicosign 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 certificatesThe 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).
apiVersion: kyverno.io/v1kind: ClusterPolicymetadata:name: verify-shop-imagesspec:webhookConfiguration:timeoutSeconds: 30rules:- name: require-release-signaturematch:any:- resources:kinds: [Pod]namespaces: [shop]verifyImages:- imageReferences: ["registry.acme.dev/shop/*"]failureAction: Audit # switch to Enforce after the report is cleanattestors:- entries:- keyless:issuer: https://token.actions.githubusercontent.comsubjectRegExp: ^https://github\.com/acme/shop/\.github/workflows/release\.yml@refs/tags/v.*$rekor:url: https://rekor.sigstore.dev
kubectl run demo -n shop --image=registry.acme.dev/shop/api:hotfix-by-handError from server: admission webhook "mutate.kyverno.svc-fail" denied the request:resource Pod/shop/demo was blocked due to the following policiesverify-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 forAudit 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.
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.