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

URL: https://www.devopsness.com/blog/sign-container-images-cosign
Author: Kiril Urbonas
Published: 2026-10-31
Updated: 2026-10-31
Categories: DevOps
Tags: Supply Chain, Security, Sigstore, GitHub Actions, DevSecOps

Summary: 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.

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

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]
> 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](/blog/kyverno-vs-opa-gatekeeper) 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](/blog/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](/blog/supply-chain-security-sbom-attestation). The caveat: enforce on your own registry glob first, not on every third-party image in the cluster.

## Sources

- [Sigstore docs: CI quickstart (cosign in GitHub Actions)](https://docs.sigstore.dev/quickstart/quickstart-ci/)
- [Sigstore docs: Verifying signatures](https://docs.sigstore.dev/cosign/verifying/verify/)
- [GitHub docs: Using artifact attestations](https://docs.github.com/en/actions/how-tos/secure-your-work/use-artifact-attestations/use-artifact-attestations)
- [Kyverno docs: ImageValidatingPolicy](https://kyverno.io/docs/policy-types/image-validating-policy/)

