Skip to main content
Sign Container Images With Cosign: Keyless GitHub Actions Workflow, SBOM and In-Cluster Checks

Sign Container Images With Cosign: Keyless GitHub Actions Workflow, SBOM and In-Cluster Checks

KU
• 7 min read•0 views

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.

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

YAML
# .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:

Bash
$ 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:

YAML
# 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 against refs/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 --type to 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 to none, so a permissions block that only grants packages: write leaves id-token at none.

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

React
Share this post
Cite this article
Plain text

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

Markdown

[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)

KU

Kiril Urbonas

AI Engineer

585 articles
View all articles by Kiril Urbonas

You might have missed

Evergreen posts worth revisiting.