How to Sign and Verify Container Images with Cosign Keyless (2026)

Oct 31, 2026 · 11 min · Ajay Kumar

This guide is for platform and DevOps engineers who want every container image they build signed, and every cluster they run to refuse images that are not. By the end you will have a GitHub Actions workflow that builds an image, signs it keyless with cosign, attaches an SBOM and SLSA provenance, and a Kubernetes admission policy that only admits images signed by that exact workflow. Commands were checked against the official docs and CLI reference for cosign v3.1.4 (20 October 2026), sigstore/cosign-installer v4.1.2, actions/attest v4.2.2, Kyverno v1.19.2 (Helm chart 3.9.2) and Sigstore policy-controller Helm chart 0.10.8 (which ships policy-controller v0.13.1; the newest app release is v0.15.1). You need a GitHub repository that pushes to GHCR, a cluster with kubectl and Helm 3 or later, and jq. If you want the background on Fulcio, Rekor and why keyless works at all, read Sigstore explained first.

One note on output: the expected output shown below is the text cosign v3.1.4 prints, taken from its source, not a capture from my machine.

From git push to an admitted pod: where each step in this guide runs 1. Build, push buildx pushes image records the digest sha256:... 2. cosign sign keyless, GitHub OIDC id-token: write SAN = workflow URI 3. Attest SBOM: cosign attest SLSA: actions/attest stored as referrers 4. Admission Kyverno IVP or policy-controller identity + issuer 5. Pod runs tag rewritten to the digest that was verified Denied no matching signatures or wrong identity Verify locally first: cosign verify IMAGE@DIGEST --certificate-identity ... --certificate-oidc-issuer ...
The signing and enforcement path built in Steps 1 to 7. Field and flag names follow the cosign v3.1.4 CLI reference, GitHub's artifact attestation docs and the Kyverno 1.19 ImageValidatingPolicy docs.

Step 1: Install cosign v3.1.4 and verify the binary

Install a pinned version and verify it with cosign's own signature, using the identity the Sigstore project publishes in its installation docs. Bootstrapping from a checksum first means you are not trusting an unverified binary to verify itself.

VERSION=v3.1.4
OS=linux-amd64   # or darwin-arm64, linux-arm64, ...
BASE=https://github.com/sigstore/cosign/releases/download/${VERSION}

curl -sSLO "${BASE}/cosign-${OS}"
curl -sSLO "${BASE}/cosign-${OS}.sigstore.json"
curl -sSLO "${BASE}/cosign_checksums.txt"

# 1. Checksum (catches a corrupted or swapped download)
grep " cosign-${OS}$" cosign_checksums.txt | sha256sum -c -
# expected: cosign-linux-amd64: OK

chmod +x "cosign-${OS}"
# 2. Signature, using the binary you just checksummed
"./cosign-${OS}" verify-blob "cosign-${OS}" \
  --bundle "cosign-${OS}.sigstore.json" \
  --certificate-identity keyless@projectsigstore.iam.gserviceaccount.com \
  --certificate-oidc-issuer https://accounts.google.com
# expected: Verified OK

install -m 0755 "cosign-${OS}" ~/.local/bin/cosign
cosign version | grep GitVersion
# GitVersion:    v3.1.4

On macOS use shasum -a 256 -c - instead of sha256sum -c -. Stay on 3.1.3 or later: v3.1.3 fixed GHSA-fx35-mq7g-6g98, a verification bypass via a public key in a legacy bundle.

Step 2: Practise verification on an image that is already signed

Before signing anything, learn what a passing check looks like. Kubernetes signs its release images keyless, and the Kubernetes docs publish the identity to expect:

cosign verify registry.k8s.io/kube-apiserver-amd64:v1.37.0 \
  --certificate-identity krel-trust@k8s-releng-prod.iam.gserviceaccount.com \
  --certificate-oidc-issuer https://accounts.google.com | jq '.[0].critical'

On stderr cosign prints the checks it ran:

Verification for registry.k8s.io/kube-apiserver-amd64:v1.37.0 --
The following checks were performed on each of these signatures:
  - 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

Now change the identity to something wrong and run it again. You get none of the expected identities matched what was in the certificate, got subjects [...], followed by the real subject. That error is the feature: a valid signature from the wrong signer is exactly what a compromised pipeline produces, which is why cosign has refused to verify keyless signatures without --certificate-identity (or --certificate-identity-regexp) and --certificate-oidc-issuer since 2.0.

Step 3: Sign the image keyless in GitHub Actions

The workflow needs id-token: write so the job can request an OIDC token for Fulcio, the same mechanism as GitHub Actions OIDC for cloud credentials. Sign the digest that the build step outputs, never a tag: tags move, digests do not.

# .github/workflows/release.yml
name: release
on:
  push:
    tags: ["v*"]

permissions: {}

jobs:
  image:
    runs-on: ubuntu-24.04
    permissions:
      contents: read
      packages: write        # push to GHCR
      id-token: write        # OIDC token for Fulcio and actions/attest
      attestations: write    # store provenance with GitHub
    env:
      IMAGE: ghcr.io/${{ github.repository }}
    steps:
      - uses: actions/checkout@v7.0.1
        with:
          persist-credentials: false

      - uses: sigstore/cosign-installer@v4.1.2
        with:
          cosign-release: "v3.1.4"

      - uses: docker/setup-buildx-action@v4.4.1

      - uses: docker/login-action@v4.7.0
        with:
          registry: ghcr.io
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - id: build
        uses: docker/build-push-action@v7.4.0
        with:
          context: .
          push: true
          tags: ${{ env.IMAGE }}:${{ github.ref_name }}

      - name: Sign the image digest (keyless)
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: cosign sign --yes "${IMAGE}@${DIGEST}"

In production, pin each action to a full commit SHA rather than a tag; the reasons are in the GitHub Actions security post. With cosign v3 the signature is written as a Sigstore bundle attached to the image through the OCI 1.1 referrers mechanism, per the signing docs. If you push a multi-arch index, add --recursive to sign each platform manifest as well as the index.

The certificate's subject is the workflow file at the ref that ran it, for example https://github.com/acme/api/.github/workflows/release.yml@refs/tags/v1.4.0. Write that down: it is what every verifier will match.

Step 4: Verify the signature with certificate identity flags

From any machine, with no GitHub login:

IMAGE=ghcr.io/acme/api
DIGEST=$(crane digest ${IMAGE}:v1.4.0)   # or: docker buildx imagetools inspect

cosign verify "${IMAGE}@${DIGEST}" \
  --certificate-identity-regexp '^https://github\.com/acme/api/\.github/workflows/release\.yml@refs/tags/v[0-9]+\.[0-9]+\.[0-9]+$' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  --certificate-github-workflow-repository acme/api \
  --certificate-github-workflow-trigger push

Anchor the regexp at both ends and escape the dots. An unanchored github.com/acme/ also matches github.com/acme/anything-else, and github.com/acme-evil/ if you forget the slash. The --certificate-github-workflow-* flags check additional claims Fulcio embeds from the GitHub token (repository, trigger, SHA, ref, workflow name). Run cosign tree "${IMAGE}@${DIGEST}" to see every signature and attestation attached to an image.

Decide now how strict the identity should be. Matching only tag refs (@refs/tags/v...) means an image built from a feature branch, even by the right workflow file, will never verify, which is usually what you want for production. If several repositories share one build, move it into a reusable workflow: the certificate then names the reusable workflow, so one identity covers every caller, and GitHub treats that isolation as the route to SLSA Build Level 3.

Step 5: Attach SBOM and SLSA provenance attestations

A signature says who approved the digest. Attestations say what is inside it and how it was built. Add these steps after signing:

      - name: Generate a CycloneDX SBOM from the pushed image
        uses: anchore/sbom-action@v0.24.3
        with:
          image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
          format: cyclonedx-json
          output-file: sbom.cdx.json
          upload-artifact: false

      - name: Attest the SBOM with cosign
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          cosign attest --yes --type cyclonedx \
            --predicate sbom.cdx.json "${IMAGE}@${DIGEST}"

      - name: Attest SLSA build provenance
        uses: actions/attest@v4.2.2
        with:
          subject-name: ${{ env.IMAGE }}            # no tag here
          subject-digest: ${{ steps.build.outputs.digest }}
          push-to-registry: true

actions/attest generates SLSA build provenance when no SBOM or predicate input is given, and actions/attest-build-provenance is now a wrapper around it. GitHub's attestation how-to covers the SBOM variant (sbom-path) if you prefer GitHub for both. If you also run a scanner, the Trivy and Grype guide shows how to scan the same SBOM.

Verify both kinds of attestation:

# SBOM attestation made by cosign
cosign verify-attestation "${IMAGE}@${DIGEST}" --type cyclonedx \
  --certificate-identity-regexp '^https://github\.com/acme/api/\.github/workflows/release\.yml@' \
  --certificate-oidc-issuer https://token.actions.githubusercontent.com \
  | jq -r '.payload' | base64 -d | jq '.predicate.metadata.component.name'

# SLSA provenance made by actions/attest (needs gh and docker login ghcr.io)
gh attestation verify "oci://${IMAGE}:v1.4.0" -R acme/api

Step 6: Enforce signatures in Kubernetes with Kyverno ImageValidatingPolicy

Kyverno 1.19 officially deprecated ClusterPolicy, including its verifyImages rules, with removal planned for 1.20 (estimated November 2026). New image checks belong in ImageValidatingPolicy, which is stable at policies.kyverno.io/v1. If you are choosing between engines, see Kyverno vs Gatekeeper vs ValidatingAdmissionPolicy.

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace --version 3.9.2
# require-signed-api.yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
  name: require-signed-acme-images
spec:
  validationActions: [Audit]        # switch to [Deny] after a clean week
  failurePolicy: Fail
  webhookConfiguration:
    timeoutSeconds: 20               # 1 to 30; default 10
  matchConstraints:
    resourceRules:
      - apiGroups: [""]
        apiVersions: ["v1"]
        operations: ["CREATE", "UPDATE"]
        resources: ["pods"]
  matchImageReferences:
    - glob: "ghcr.io/acme/*"
  attestors:
    - name: cosign
      cosign:
        keyless:
          identities:
            - issuer: https://token.actions.githubusercontent.com
              subjectRegExp: '^https://github\.com/acme/[^/]+/\.github/workflows/release\.yml@refs/tags/v.*$'
        ctlog:
          url: https://rekor.sigstore.dev
  attestations:
    - name: slsa
      intoto:
        type: https://slsa.dev/provenance/v1
  validations:
    - expression: >-
        images.containers.map(image, verifyImageSignatures(image, [attestors.cosign])).all(e, e > 0)
      message: "image must be signed by an acme release workflow"
    - expression: >-
        images.containers.map(image, verifyAttestationSignatures(image, attestations.slsa, [attestors.cosign])).all(e, e > 0)
      message: "image must carry SLSA provenance from an acme release workflow"
kubectl apply -f require-signed-api.yaml
kubectl run unsigned --image=ghcr.io/acme/unsigned:latest --dry-run=server
kubectl get policyreports -A

By default (validationConfigurations.mutateDigest: true) Kyverno rewrites the tag in the pod spec to the digest it verified, so the node pulls exactly what was checked. Kyverno's own conformance tests verify GitHub-generated SLSA v1 attestations with this pattern. Start in Audit because the first run always finds something: a sidecar from a vendor chart, a debug image someone pulled by hand, an init container from a public registry. Those either get their own attestor with the vendor's published identity or a narrower matchImageReferences glob; do not widen the regexp to make the noise go away. For a private registry, add spec.credentials.secrets naming a pull secret in the Kyverno namespace, or providers such as amazon or github.

Step 7 (alternative): Enforce with Sigstore policy-controller

If you do not run Kyverno, the Sigstore project's policy-controller does one job: image signature and attestation admission. It only acts on namespaces that opt in, and rejects images matched by no policy unless you change no-match-policy in its ConfigMap.

helm repo add sigstore https://sigstore.github.io/helm-charts
helm install policy-controller -n cosign-system --create-namespace \
  sigstore/policy-controller --version 0.10.8
kubectl label namespace prod policy.sigstore.dev/include=true
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: acme-release-signed
spec:
  mode: warn                      # remove (defaults to enforce) once quiet
  images:
    - glob: "ghcr.io/acme/**"
  authorities:
    - signatureFormat: bundle       # cosign v3 default; the CRD defaults to legacy
      keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subjectRegExp: '^https://github\.com/acme/[^/]+/\.github/workflows/release\.yml@refs/tags/v.*$'
      ctlog:
        url: https://rekor.sigstore.dev

The signatureFormat: bundle line matters: the ClusterImagePolicy CRD in chart 0.10.8 defaults each authority to legacy, while cosign v3 writes Sigstore bundles, so without it policy-controller looks in the wrong place and finds nothing. Because unlabelled namespaces are untouched, roll it out namespace by namespace, and label kube-system only once you have policies for the images it runs.

Troubleshooting

Symptom Likely cause Fix
none of the expected identities matched what was in the certificate, got subjects [...] Identity differs from the SAN: branch vs tag ref, reusable workflow path, typo Copy the subject from the error; use an anchored --certificate-identity-regexp
no signatures found You signed a different digest, a tag was re-pushed, or only the index was signed Sign steps.build.outputs.digest; add --recursive for multi-arch; check with cosign tree
none of the attestations matched the predicate type or no matching attestations --type does not match the predicate type, or wrong identity Use --type cyclonedx, slsaprovenance1 or the full predicate URI; recheck identity
Workflow fails getting an OIDC token id-token: write missing at job level, or a fork pull request Add the permission; sign only on push or tag events, not fork PRs
Old verifier cannot read new signatures Verifier on cosign v2 older than 2.6, or an old Kyverno or policy-controller Upgrade verifiers; as a stopgap sign with the deprecated --new-bundle-format=false
policy-controller rejects an image that cosign verify accepts Authority left on the CRD default signatureFormat: legacy Set signatureFormat: bundle on the authority
Kyverno admission times out Webhook cannot reach the registry, Rekor or TUF in time Allow egress to ghcr.io, rekor.sigstore.dev and tuf-repo-cdn.sigstore.dev; raise timeoutSeconds
Kyverno denies a private image that is signed No registry credentials for signature lookup Set spec.credentials.secrets (in the Kyverno namespace) or providers
policy-controller ignores a namespace Namespace lacks the opt-in label kubectl label namespace NAME policy.sigstore.dev/include=true
Private repo attestation not in public Rekor Private repos use GitHub's Sigstore instance, which has no public log Verify with gh attestation verify; this is expected behaviour

FAQ

Do I need to manage keys to sign images with cosign?

No. In keyless mode cosign creates a key pair per signing operation, gets a 10-minute certificate from Fulcio bound to your OIDC identity, logs the signature to Rekor and discards the key. Key-based signing with KMS is still supported if you need to work offline.

Should I sign the tag or the digest?

The digest. A tag can be moved to different content after you sign it, while a digest is the content. Kyverno's default digest mutation then pins the pod to the digest it verified.

Kyverno verifyImages or ImageValidatingPolicy?

Use ImageValidatingPolicy for anything new. Kyverno 1.19 deprecated ClusterPolicy, verifyImages included, and plans to remove it in 1.20. Existing verifyImages rules keep working on 1.19, so migrate before you upgrade.

What should the certificate identity be for GitHub Actions?

The workflow file URI at the ref that ran it, such as https://github.com/ORG/REPO/.github/workflows/release.yml@refs/tags/v1.4.0, with issuer https://token.actions.githubusercontent.com. If the build runs in a reusable workflow, the identity is the reusable workflow's path, not the caller's.

When I set this up for a new service, the first thing I check is the identity regexp, by running cosign verify against an image signed from a feature branch and confirming it fails. Then I run the admission policy in audit mode for a week, read the policy reports, and only then switch it to deny, with a namespace for break-glass images that is labelled and alerted on. Signing takes ten minutes to add; the policy that refuses the wrong signer is the part that does the work. Official references: cosign docs, cosign verification, Kyverno ImageValidatingPolicy, policy-controller and GitHub artifact attestations.

Related: How to Scan Container Images with Trivy and Grype, How to Use GitHub Actions OIDC With AWS, GCP and Azure and Kyverno vs Gatekeeper vs ValidatingAdmissionPolicy in 2026.

I'm Ajay Kumar — I build and operate PandaStack, an open-source Firecracker microVM cloud for AI agents, and write about the infrastructure and tooling around it.

Need this kind of infrastructure work? See what I do or email hello@ajayk.sh.


Related