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.

What Each Signing Model Asks You to Protect Key-based signing requires storing and rotating a long-lived private key, and a verification asserts only that the holder of that key signed. Keyless signing issues a short-lived certificate to the workflow identity, so there is nothing to store or rotate, and verification asserts which repository, workflow and branch produced the image. Key-based signing a long-lived private key you must store rotation is a project nobody schedules a leak is silent and hard to detect Verification asserts: "someone holding this key signed this image" — and nothing about where or how it was built. Keyless workflow identity a certificate valid for minutes, then gone nothing to rotate, nothing to leak the signing event is publicly logged Verification asserts: "built by this workflow, in this repository, on this branch" — which is the statement a policy actually needs.

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.

An Unanchored Pattern Matches More Than You Meant A pattern without start and end anchors matches the intended repository and also a fork with a longer name, a repository whose name merely contains the intended one, and any workflow file inside them. An anchored pattern naming the workflow path and the branch matches exactly one signing identity. Unanchored: github.com/example-org/web also matches: github.com/example-org/web-attacker-fork — a repository anyone can create also matches: any workflow file in those repositories, on any branch Anchored, with the workflow path and branch matches exactly one signing identity — start anchor, repository, workflow file, branch, end anchor

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.

Two Checks, at Two Different Moments The pipeline verifies the image it just built, which proves the artifact was correct at build time. The admission controller verifies again at the moment the platform is asked to run it, which proves nothing was substituted in the registry or promoted by hand in between. Only the second check covers the window where most substitutions happen. Build and sign digest, signature, attestations Registry tags can move here, digests cannot Admission check identity, issuer and provenance, by digest Runs or refused The middle box is where substitutions actually happen — a repointed tag, a hand-promoted image, a compromised registry credential — which is precisely why the second check exists.

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.