Verifying Container Image Signatures With Cosign
Between the build that produced an image and the Kubernetes cluster that runs it there is a registry, a set of credentials, and a mutable tag. Any of the three can be the point at which what deploys stops being what was built — a compromised registry credential, a tag repointed by a colleague’s script, an image promoted by hand at two in the morning during an incident.
Signing closes the gap by binding a cryptographic assertion to the image digest, and verification at admission makes that assertion load-bearing rather than decorative. This guide sets up keyless signing with workflow identity, attaches provenance and inventory attestations, and enforces the policy where it counts. It is part of the Build Pipeline & CI Runner Hardening guide within Supply Chain & Dependency Security.
Prerequisites
- A CI platform able to mint a workflow identity token — the same mechanism used for cloud federation
- A registry that accepts signature and attestation artifacts alongside images
- An admission controller in your orchestrator, or an equivalent gate in the deployment path
- An inventory generator, if you intend to attach a bill of materials
Expected Outcomes
- Every published image signed by the workflow that built it, with no key to manage
- Provenance and inventory attestations bound to the image digest
- Verification asserting identity and issuer, always against a digest
- Unsigned or unexpected images refused at admission, not merely warned about
Step 1: Sign With the Workflow’s Own Identity
permissions:
id-token: write # mint the identity used for the signing certificate
packages: write # push the image and its signature
contents: read
steps:
- name: Build and push by digest
id: push
run: |
IMAGE=ghcr.io/example-org/web
docker buildx build --push -t "$IMAGE:${GITHUB_SHA}" .
DIGEST=$(docker buildx imagetools inspect "$IMAGE:${GITHUB_SHA}" --format '{{"{{"}}.Manifest.Digest{{"}}"}}')
echo "ref=$IMAGE@$DIGEST" >> "$GITHUB_OUTPUT"
- name: Sign the digest, keylessly
env: { COSIGN_EXPERIMENTAL: "1" }
run: cosign sign --yes "${{ steps.push.outputs.ref }}"
Two things are happening. The image is referenced by digest from the moment it is pushed, so nothing downstream depends on a tag. And the signature is produced with a short-lived certificate issued to the workflow identity — there is no private key in the repository, in a secret store, or on a developer machine, which removes the entire category of key-custody incidents.
Step 2: Attach Provenance and an Inventory
- name: Attest provenance
run: |
cosign attest --yes --predicate provenance.json \
--type slsaprovenance "${{ steps.push.outputs.ref }}"
- name: Attest the component inventory
run: |
syft "${{ steps.push.outputs.ref }}" -o cyclonedx-json > sbom.json
cosign attest --yes --predicate sbom.json \
--type cyclonedx "${{ steps.push.outputs.ref }}"
Attestations are what let a policy ask questions beyond “is it signed”: which repository built it, which builder ran, whether the inventory contains a component you have banned. They are bound to the digest, so they travel with the image rather than with the tag.
Step 3: Verify by Identity and Issuer, Against a Digest
# Resolve the tag once, then use the digest everywhere afterwards.
DIGEST=$(crane digest ghcr.io/example-org/web:2026.07.31)
REF="ghcr.io/example-org/web@${DIGEST}"
cosign verify "$REF" \
--certificate-identity-regexp '^https://github\.com/example-org/web/\.github/workflows/release\.yml@refs/heads/main$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \
--output json > verification.json
cosign verify-attestation "$REF" --type slsaprovenance \
--certificate-identity-regexp '^https://github\.com/example-org/web/' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' >/dev/null
Anchor the identity expression at both ends. An unanchored pattern such as github.com/example-org/web also matches github.com/example-org/web-attacker-fork, which is a small mistake with a large consequence — and it is the single most common error in verification policies.
Step 4: Enforce It at Admission
Verification in the pipeline proves the image was good when the pipeline looked. Admission control proves it at the moment the Kubernetes cluster runs it.
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-signed-web-images
spec:
images:
- glob: "ghcr.io/example-org/**"
authorities:
- keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: https://token.actions.githubusercontent.com
subjectRegExp: '^https://github\.com/example-org/[^/]+/\.github/workflows/release\.yml@refs/heads/main$'
attestations:
- name: must-have-provenance
predicateType: slsaprovenance
mode: enforce # not "warn" — a warning is not a control
Roll this out in warn mode first, watch what it would have blocked for a full deployment cycle, then switch to enforce. The images it flags in that window are usually informative: a base image someone promoted by hand, a hotfix built locally, a repository that was never wired into the signing workflow.
Verification
# 1. A signed image passes with the expected identity.
cosign verify "$REF" \
--certificate-identity-regexp '^https://github\.com/example-org/web/\.github/workflows/release\.yml@refs/heads/main$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' | jq '.[0].optional.Subject'
# 2. An image signed by a different workflow must fail verification.
cosign verify "$FORK_REF" --certificate-identity-regexp '…release\.yml@refs/heads/main$' \
--certificate-oidc-issuer 'https://token.actions.githubusercontent.com'
# Expected: error — no matching signatures.
# 3. Admission actually refuses an unsigned image.
kubectl run unsigned-test --image=docker.io/library/nginx:1.27 --restart=Never
# Expected: admission webhook denies the request.
# 4. Provenance is present and names the expected builder.
cosign verify-attestation "$REF" --type slsaprovenance … | jq -r '.payload' | base64 -d \
| jq '.predicate.builder.id'
Test three is the one worth running after every cluster upgrade: an admission webhook that has silently stopped being called fails open, and nothing else in the system will tell you.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| Verification passes for a fork’s image | Unanchored identity expression | Anchor the pattern at both ends and include the workflow path and ref |
| Signing fails with a permission error | Identity-token permission missing on the job | Add the permission at job level, not only at workflow level |
| Admission denies legitimate base images | Policy glob covers third-party registries | Scope the glob to your own namespace and handle base images separately |
| Verification succeeds but the wrong image runs | Verified a tag, deployed a tag | Resolve to a digest once and use that digest for both steps |
| Attestation verification fails after a tooling upgrade | Predicate type name changed | Pin the tooling version and assert the predicate type in the policy |
| Policy has no effect | Controller in warn mode, or webhook not registered | Confirm the mode is enforce and that the webhook is reachable |
Common Implementation Mistakes
Frequently Asked Questions
Why keyless signing rather than a managed key?
Because the hardest part of signing is key custody, and keyless removes it entirely. A short-lived certificate is issued to the workflow identity, used to sign, and expires — there is no long-lived private key to store, rotate, or leak. The verification is also more useful: instead of asserting “signed by a key we hold”, it asserts “built by this workflow, in this repository, on this branch”, which is the statement an admission policy actually wants to evaluate.
What does verifying by tag get wrong?
A tag is mutable, so verifying by tag creates a race between the check and the pull: you verify what the tag points at now, and the Kubernetes cluster fetches whatever it points at a moment later. Resolve the tag to a digest once, verify that digest, and deploy the same digest. Any pipeline that verifies and deploys as separate steps without carrying the digest between them has verified nothing in particular.
Should verification also require an attestation, not just a signature?
For anything reaching production, yes. A signature says the image came from your workflow; an attestation says how it was built and what is inside it. Requiring provenance lets the policy check the source repository and the builder, and requiring an inventory lets it reject images containing a banned component. Start by requiring the signature, then add attestation predicates once the pipeline produces them reliably.
Related
- Build Pipeline & CI Runner Hardening — the parent guide covering the pipeline as a production system
- OIDC Federation Instead of Long-Lived Cloud Keys — the same workflow identity used for cloud access
- Generating a CycloneDX SBOM in GitHub Actions — producing the inventory this attests
- License and VEX Gates From an SBOM — turning the attested inventory into a policy decision