Sign Container Images With Cosign: Keyless GitHub Actions Workflow, SBOM and In-Cluster Checks
One workflow that builds, signs keylessly with GitHub OIDC, attaches an SBOM and SLSA provenance, plus the Kyverno policy that refuses anything unsigned. Copy it, change three names, ship it.
Key takeaways
- Sign container images with cosign keyless signing in GitHub Actions by granting the job id-token write permission and running cosign sign on the image digest.
- Attach an SBOM with cosign attest and provenance with actions/attest, then enforce both in the cluster with a Kyverno ImageValidatingPolicy that pins the workflow identity.
On this page
To sign a container image with cosign in GitHub Actions, give the job id-token: write, push the image, and run cosign sign --yes <image>@<digest>. No keys to store: cosign trades the workflow's OIDC token for a short-lived certificate that names the exact workflow file and branch. Verification then checks that identity, not a secret.
Verified against
cosign 3.1.4, sigstore/cosign-installer 4.1.2, actions/attest 4.2.2, anchore/sbom-action 0.24.3, Kyverno 1.19.2, against docs.sigstore.dev, the cosign v3.1.4 CLI reference and changelog, GitHub's artifact attestation docs and kyverno.io. Checked October 2026.
What keyless signing actually proves
A keyless signature binds the image digest to an identity like https://github.com/acme/app/.github/workflows/release.yml@refs/heads/main, issued by https://token.actions.githubusercontent.com. The signature, certificate and transparency log proof are stored next to the image. Anyone verifying asks one question: was this digest signed by that workflow on that branch?
That is a stronger claim than "signed with our key", because a stolen key can sign anything, forever, while a keyless certificate only exists for the duration of one workflow run. As of cosign v3, the standardized Sigstore bundle format and storing signatures as OCI 1.1 referring artifacts are on by default, per the v3.0.0 changelog.
The full workflow
# .github/workflows/release.yml
name: release
on:
push:
branches: [main]
permissions:
contents: read
jobs:
build-sign:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write # push to ghcr.io
id-token: write # OIDC token for keyless signing
attestations: write # GitHub build provenance
env:
IMAGE: ghcr.io/acme/app # lowercase, no tag
steps:
- uses: actions/checkout@v7.0.1
with:
persist-credentials: false
- 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:
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
- uses: sigstore/cosign-installer@v4.1.2
with:
cosign-release: v3.1.4
- name: Sign the digest, keyless
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes "${IMAGE}@${DIGEST}"
- name: Generate an SPDX SBOM
uses: anchore/sbom-action@v0.24.3
with:
image: ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}
format: spdx-json
output-file: sbom.spdx.json
- name: Attach the SBOM as a signed attestation
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign attest --yes --type spdxjson \
--predicate sbom.spdx.json "${IMAGE}@${DIGEST}"
- name: SLSA build provenance
uses: actions/attest@v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.build.outputs.digest }}
push-to-registry: true
Three things in that file are load-bearing. We sign image@sha256:..., never a tag, because a tag can be moved to a different image after signing; the Sigstore CI quickstart does the same. We pin cosign-release because cosign-installer 4.1.2 still defaults to v3.0.6. And --yes skips the interactive confirmation, which would otherwise hang a CI job.
The provenance step uses GitHub's actions/attest, which the attest-build-provenance README now says new setups should use directly. It pushes a Sigstore-signed SLSA provenance attestation to the registry next to the image. One limit: on Free, Pro and Team plans, artifact attestations work only in public repositories; private repos need GitHub Enterprise Cloud.
Verify before you enforce
Run these from a laptop or a later pipeline stage. Both identity flags are required for keyless verification:
$ IMAGE=ghcr.io/acme/app@sha256:<digest>
$ ID='https://github.com/acme/app/.github/workflows/release.yml@refs/heads/main'
# The signature
$ cosign verify "$IMAGE" \
--certificate-identity="$ID" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com
# The SBOM attestation
$ cosign verify-attestation "$IMAGE" --type spdxjson \
--certificate-identity="$ID" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com
# The GitHub provenance
$ gh attestation verify "oci://$IMAGE" -R acme/app
Releasing from tags instead of main? The identity then ends in @refs/tags/v1.4.0, so use --certificate-identity-regexp '^https://github.com/acme/app/.github/workflows/release.yml@refs/tags/v' rather than loosening the issuer.
Enforce it in the cluster
Kyverno's ImageValidatingPolicy (stable since 1.18) checks both the signature and the SBOM attestation at admission. This mirrors the keyless example in the Kyverno docs, narrowed to one workflow:
# kyverno/require-signed-images.yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: require-signed-images
spec:
validationActions: [Deny]
webhookConfiguration:
timeoutSeconds: 15
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: [v1]
operations: [CREATE, UPDATE]
resources: [pods]
matchImageReferences:
- glob: "ghcr.io/acme/*"
attestors:
- name: cosign
cosign:
keyless:
identities:
- subject: "https://github.com/acme/app/.github/workflows/release.yml@refs/heads/main"
issuer: "https://token.actions.githubusercontent.com"
ctlog:
url: "https://rekor.sigstore.dev"
attestations:
- name: sbom
intoto:
type: https://spdx.dev/Document
validations:
- expression: >-
images.containers.map(image, verifyImageSignatures(image, [attestors.cosign])).all(e, e > 0)
message: "Image is not signed by the acme release workflow"
- expression: >-
images.containers.map(image, verifyAttestationSignatures(image, attestations.sbom, [attestors.cosign])).all(e, e > 0)
message: "Image has no signed SPDX SBOM attestation"
https://spdx.dev/Document is the in-toto predicate type cosign writes for --type spdxjson. Start with validationActions: [Audit] for a week, read the policy reports, then flip to Deny. If you are still choosing an admission engine, our Kyverno vs OPA Gatekeeper comparison covers the trade-offs; Sigstore's own policy-controller is the lighter alternative if signatures are the only thing you enforce.
The errors you will see
none of the expected identities matched what was in the certificate, got subjects [...] with issuer ...: the signature is fine, your identity string is wrong. Copy the subject from the error; the usual culprits are a renamed workflow file or a tag release checked againstrefs/heads/main.no matching signatures:: nothing signed this digest. Most often you verified a tag that was rebuilt after signing, or the signing step ran on a different digest than the one deployed.no matching attestations:: the attestation exists but under a different predicate type. Match--typeto what you attested.- The sign step fails to get an OIDC token: the job is missing
id-token: write. Once you set any permission explicitly, GitHub sets every permission you didn't list tonone, so apermissionsblock that only grantspackages: writeleavesid-tokenatnone.
Where this breaks
Signing proves who built an image, not that the image is safe; pair it with container vulnerability scanning. Public-good Sigstore logs every signing identity in a public transparency log, so private repo and workflow names become visible. And cosign v3's bundle-and-referrers layout means old verifiers may not find new signatures: upgrade cosign, Kyverno and any policy-controller together, and run cosign verify against a real deployed digest before turning on Deny.
Questions people ask
How do I sign a Docker image with cosign in GitHub Actions?
Give the job id-token: write and packages: write, install cosign with sigstore/cosign-installer, push the image, then run cosign sign --yes ghcr.io/org/app@<digest> using the digest output from docker/build-push-action. No keys or secrets are needed: cosign uses the workflow's OIDC token to get a short-lived Fulcio certificate and records the signature in Rekor.
What is the certificate identity for GitHub Actions keyless signing?
It is the workflow file's URL plus the git ref, for example https://github.com/acme/app/.github/workflows/release.yml@refs/heads/main, with issuer https://token.actions.githubusercontent.com. Pass both to cosign verify with --certificate-identity and --certificate-oidc-issuer. For tag-triggered releases, use --certificate-identity-regexp anchored to your repo and workflow path.
How do I attach an SBOM to a container image with cosign?
Generate an SPDX or CycloneDX SBOM (for example with anchore/sbom-action), then run cosign attest --yes --type spdxjson --predicate sbom.spdx.json <image>@<digest>. That signs the SBOM as an in-toto attestation bound to the digest. Verify it with cosign verify-attestation --type spdxjson and the same identity flags you use for the signature.
The call we'd make
Keyless signing on every image you push from CI, by digest, with an SBOM attestation and GitHub provenance, then a Kyverno ImageValidatingPolicy in Audit mode that becomes Deny once the reports are clean. It is an afternoon of work and closes the "who built this?" question for good. The broader picture of SBOMs and attestations lives in supply chain security with SBOMs and attestations. The caveat: enforce on your own registry glob first, not on every third-party image in the cluster.
Sources
Cite this article
Kiril Urbonas (2026). Sign Container Images With Cosign: Keyless GitHub Actions Workflow, SBOM and In-Cluster Checks. DevOpsNess. https://www.devopsness.com/blog/sign-container-images-cosign
[Sign Container Images With Cosign: Keyless GitHub Actions Workflow, SBOM and In-Cluster Checks](https://www.devopsness.com/blog/sign-container-images-cosign) (Kiril Urbonas, DevOpsNess, 2026-10-31)
More from DevOps
Explore more articles in this category
15 SRE Interview Questions With Answers, From SLOs to On-Call
SRE interviews test numbers and judgment. Fifteen questions on SLIs, error budgets, incident command, toil, capacity, on-call and postmortems, plus a systems-design scenario, each with the answer that scores and the one that loses the room.
Fix Prometheus High Cardinality: 6 Commands to Find and Drop Bad Series
When Prometheus runs out of memory, one label is almost always to blame. Six commands to find it, the relabel rules that drop it, and the scrape limits that stop it coming back.
OpenTelemetry Collector on Kubernetes: Agent vs Gateway, With Configs That Work
Run a DaemonSet agent on every node and a small gateway Deployment behind it. Two Helm values files, the processor order that keeps the Collector alive, and the errors you will hit first.
You might have missed
Evergreen posts worth revisiting.