How to Sign and Verify Container Images with Cosign Keyless (2026)
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.
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
How to Encrypt Kubernetes Secrets in Git with SOPS and age (2026)
Step-by-step SOPS 3.13.3 and age 1.3.2 setup: generate age keys, write .sops.yaml creation rules, encrypt and edit Secrets, decrypt with Flux kustomize-controller or Argo CD via KSOPS, rotate keys safely, and fix MAC mismatch and no identity matched errors.
11 minOct 31, 2026How to Fix Kubernetes RBAC Forbidden: User Cannot List Resource (2026)
Fix the Kubernetes error 'Error from server (Forbidden): User cannot list resource' step by step: parse the message, check identity with kubectl auth whoami and can-i --as, write a least-privilege Role and RoleBinding, fix service account tokens, EKS access entries, GKE IAM and AKS.
13 minOct 31, 2026How to Add Semgrep and CodeQL to GitHub Actions (2026 Guide)
Step-by-step 2026 guide to Semgrep CE 1.183 and CodeQL Action v4.39 in GitHub Actions: default vs advanced setup, pinned workflows, SARIF upload with categories, PR-only diff scans, custom rules with tests, suppressing false positives, merge gates and fixes for common errors.
13 min