Post

Keyless Container Image Signing with Sigstore Cosign

A practical walkthrough of signing and verifying container images with cosign's keyless mode: how Fulcio and Rekor replace long-lived keys, a working GitHub Actions job, and the cosign v3 migration details that trip people up.

Keyless Container Image Signing with Sigstore Cosign

The problem with a tag

docker pull myapp:latest tells you nothing about what you are actually running. A tag is a mutable pointer: anyone with push access to the registry can move it to a different image tomorrow, and every host that pulls latest picks up whatever it now points to, with no record of the change. Pinning to a digest (myapp@sha256:...) fixes the mutability problem, but it does not tell you who pushed that digest or whether it came from the build pipeline you think it came from.

Image signing closes that second gap. A signature over the image digest lets a deployment step ask a narrower question than “does this image exist in the registry”: it asks “was this exact digest produced and signed by an identity I trust, and has that signature been tampered with since.” That is the property container signing gives you, and it is a smaller, more mechanical claim than “this image is safe” - vulnerability scanning is a separate job.

The traditional way to sign anything is with a long-lived private key: generate a keypair, guard the private half, sign with it, publish the public half for verification. That works, but it hands you a new secret to rotate, store, and eventually leak. Sigstore removes the long-lived key from the picture and Cosign is the CLI that talks to it.

What keyless signing actually verifies

“Keyless” is a slight misnomer. Every signature still involves a real key pair, but it is generated fresh for a single signing operation and thrown away seconds later. What replaces the long-lived key is an identity: an OIDC token from a provider you already trust (a GitHub Actions workflow’s own OIDC issuer, a Google or GitLab account) proves who is signing, and two other Sigstore services record the fact durably so nobody has to keep the ephemeral key around:

  • Fulcio is a certificate authority that issues a short-lived X.509 certificate binding the ephemeral public key to the OIDC identity, valid for minutes.
  • Rekor is an append-only transparency log. The signature, the certificate, and the artifact digest are recorded there, so the signing event is publicly verifiable and timestamped even after the certificate has expired.
sequenceDiagram
    participant CI as CI job (GitHub Actions)
    participant OIDC as GitHub OIDC issuer
    participant Fulcio as Fulcio CA
    participant Rekor as Rekor transparency log
    participant Registry as Container registry

    CI->>OIDC: request short-lived ID token
    OIDC-->>CI: signed OIDC token (workflow identity)
    CI->>CI: generate ephemeral keypair
    CI->>Fulcio: certificate signing request + OIDC token
    Fulcio-->>CI: short-lived signing certificate
    CI->>CI: sign image digest with ephemeral key
    CI->>Rekor: upload signature + cert + digest
    Rekor-->>CI: inclusion proof
    CI->>Registry: push signature bundle alongside image

A verifier does not need the ephemeral key at all. It re-derives the digest of the image it pulled, checks the signature against the certificate in the bundle, confirms the certificate chains back to Fulcio’s root, and checks that the signing event is present in Rekor. What it does need to decide is which identity it is willing to trust, which is the part worth getting right in CI.

Signing in CI: a working GitHub Actions job

This is the shape that matters in practice: sign the image as part of the same job that builds and pushes it, using the job’s own OIDC token rather than a stored secret.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
name: build-and-sign

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write   # required: this is what lets the job mint an OIDC token

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Log in to registry
        run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u "${{ github.actor }}" --password-stdin

      - name: Build and push
        id: push
        run: |
          IMAGE="ghcr.io/${{ github.repository }}/myapp"
          docker build -t "$IMAGE:${{ github.sha }}" .
          docker push "$IMAGE:${{ github.sha }}"
          DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' "$IMAGE:${{ github.sha }}")
          echo "digest=$DIGEST" >> "$GITHUB_OUTPUT"

      - uses: sigstore/cosign-installer@v4

      - name: Sign image (keyless)
        run: cosign sign --yes "${{ steps.push.outputs.digest }}"

Two details in that job matter more than the rest of the boilerplate:

  • id-token: write is the permission that lets GitHub Actions mint the short-lived OIDC token cosign needs. Without it, cosign sign has no identity to present to Fulcio and fails.
  • --yes skips the interactive confirmation prompt that cosign normally shows before uploading to the public Rekor log. In a human terminal you want that prompt; in CI you want the job to not hang.
  • The image is signed by digest, not by the tag. Signing myapp:latest would sign whatever digest that tag happens to resolve to at the moment cosign sign runs, which is exactly the ambiguity a digest pin exists to remove.

Verifying before you deploy

Signing without verification is a no-op: the whole point is that a deploy step refuses an image whose signer it does not recognize. Cosign’s verify command checks the signature and the identity together:

1
2
3
4
cosign verify \
  --certificate-identity-regexp "^https://github.com/myorg/myapp/.github/workflows/build-and-sign.yml@refs/heads/main$" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myapp/myapp@sha256:abcd1234

The two identity flags are not optional extras, they are the actual security boundary. cosign verify with no identity constraint will happily confirm that some valid Sigstore signature exists on the image, which tells you almost nothing: anyone with a GitHub account can request a Fulcio certificate bound to their own identity and sign their own copy of an image with the same tag. What stops that from passing verification is pinning --certificate-identity (or its regex form) to the exact workflow path and ref you expect, and --certificate-oidc-issuer to the OIDC issuer you trust. Skip those two flags and the command degrades from “was this built by my pipeline” to “is this signed by somebody, somewhere.”

A deploy pipeline should run this check as a gate, failing the rollout on a non-zero exit code, rather than as a report that gets read after the fact.

Migrating to cosign v3

Cosign crossed a major version boundary this year, and the change is not cosmetic if you have an existing pipeline:

  • The default signature format changed. Cosign v3 writes the newer protobuf-based bundle format by default; v2 wrote the older simple-signing format. cosign verify in v3 reads both, but if you have older infrastructure still on cosign v2 verifying artifacts signed by a v3 pipeline, add --new-bundle-format=false on the signing side until every verifier is upgraded.
  • cosign-installer needs its own major bump. The GitHub Action sigstore/cosign-installer versions independently of cosign itself, and cosign-installer v3.x cannot install cosign v3.x binaries at all - you need cosign-installer v4, which supports installing both cosign v2 and v3. If a build starts failing to find a v3 cosign binary right after a routine “upgrade cosign” bump, check the installer action version first.
  • cosign sign-blob now requires --bundle. If anything in the pipeline signs blobs (SBOMs, provenance attestations) rather than container images directly, that command needs the extra flag under v3.

None of this changes the keyless flow conceptually. It changes exact flags and exact action versions, which is the kind of thing worth pinning explicitly (sigstore/cosign-installer@v4, not @v3 or an unpinned tag) rather than discovering at 2am when a cache-busted CI runner picks up a newer default.

Where this breaks down

Signing proves provenance, not safety. A signed image can still ship a real vulnerability, an outdated dependency, or a misconfigured entrypoint - none of that is what a signature checks. Treat cosign verification as one gate among several: a scanner for known vulnerabilities, an SBOM for what is actually in the image, and signature verification for “did this come from the pipeline I trust.” Each answers a different question, and skipping any one of them to lean harder on the others just moves the blind spot rather than closing it.

The other real limitation is that keyless signing only pushes the trust question one level up, from “do I trust this key” to “do I trust this OIDC identity and this transparency log.” That is a genuine improvement, since an OIDC provider you already run authentication through is easier to reason about and revoke than a key file sitting in a CI secret store, but it is not the elimination of trust, it is a relocation of it to infrastructure you were probably already trusting anyway.

This post is licensed under CC BY 4.0 by the author.