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.
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: writeis the permission that lets GitHub Actions mint the short-lived OIDC token cosign needs. Without it,cosign signhas no identity to present to Fulcio and fails.--yesskips 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:latestwould sign whatever digest that tag happens to resolve to at the momentcosign signruns, 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 verifyin v3 reads both, but if you have older infrastructure still on cosign v2 verifying artifacts signed by a v3 pipeline, add--new-bundle-format=falseon the signing side until every verifier is upgraded. cosign-installerneeds its own major bump. The GitHub Actionsigstore/cosign-installerversions independently of cosign itself, andcosign-installerv3.x cannot install cosign v3.x binaries at all - you needcosign-installerv4, 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-blobnow 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.
