Cosign and Sigstore: Signing Images at the Registry
A hands-on Sigstore tutorial: keyless signing with Fulcio and Rekor, keypair signing, signing a pushed image by digest, verifying in CI and at deploy, and attaching SBOM attestations.
Published on • October 17, 2026
AI Assistant

An image in a registry tells you nothing about who built it or whether the bytes changed after the push. Scanning the image - catching known CVEs in the layers - is necessary, but it answers a different question: scanning asks “is anything in here known-bad?”, signing asks “is this exactly what my pipeline produced, from the identity I trust?”.
This tutorial walks the Sigstore path end to end: how Cosign, Fulcio and Rekor fit together, keyless versus keypair signing, signing a pushed image, verifying it in CI and again at deploy time, attaching an SBOM attestation, and the failure modes that actually show up in practice. It deliberately does not overlap with vulnerability scanning; if you already run Trivy in CI, signing is the complementary gate you put after it.
The moving parts
Sigstore is an open-source project under the OpenSSF (Linux Foundation) for signing and verifying artifacts - release files, container images, binaries, SBOMs. Its own framing is that signatures should be generated with ephemeral keys so there is no key management, and recorded in a tamper-resistant public log.
Three components do the work:
| Component | Role |
|---|---|
| Cosign | The CLI client that signs and verifies artifacts (containers, blobs, attestations) |
| Fulcio | The code-signing certificate authority. Verifies your OIDC identity and issues a short-lived certificate bound to a public key |
| Rekor | The transparency log. An immutable, append-only ledger recording digest, signature and certificate for each signing event |
The keyless flow, as documented: Cosign generates a public/private key pair and sends a certificate signing request to Fulcio together with an OpenID Connect identity token - your email address, a service account, or workflow information when signing from CI. Fulcio verifies the token and issues a short-lived certificate bound to that identity and the public key. The private key is discarded after a single signing; Sigstore services never see it. After signing, the artifact digest, signature and certificate are persisted in Rekor.
Verification then checks four things in combination: the signature against the certificate’s public key, the identity in the certificate against an expected value, the certificate’s signature against Sigstore’s root of trust, and proof of inclusion in Rekor. Pass all four and you know the artifact came from the expected source and has not been altered since.
Keyless or keypair
Cosign supports both, and the trade-off is real:
| Keyless (OIDC/Fulcio) | Keypair / KMS | |
|---|---|---|
| Secret to store | None - ephemeral key per signing | Private key (cosign.key) or a KMS URI |
| Identity binding | Certificate carries identity; verified at sign time | Identity is whoever holds the key |
| Rotation | Automatic - certificates are short-lived | You own rotation and revocation |
| Verification inputs | --certificate-identity + --certificate-oidc-issuer | --key cosign.pub |
| Offline / air-gapped | Harder - needs Rekor/trusted root access | Simple - public key file is all you need |
| Network dependency at sign time | Fulcio + Rekor must be reachable | Local or your KMS only |
The Cosign README lists what it supports: keyless signing with the public-good Fulcio CA and Rekor (default), hardware and KMS signing, signing with a Cosign-generated encrypted key pair, and bring-your-own PKI. Keys are ECDSA-P256 with SHA256 hashes, stored PEM-encoded in PKCS8 format - for both ephemeral keyless signing and managed key signing.
For a managed key, generate with cosign generate-key-pair (optionally --kms with a provider URI - awskms://, gcpkms://, azurekms://, hashivault://, openbao://, k8s:// for a Kubernetes Secret, ovhcloud://), and retrieve the public key with cosign public-key --key <uri>:
$ cosign generate-key-pair --kms hashivault://cosign
$ cosign public-key --key hashivault://cosign > cosign.pub
Rule of thumb: use keyless for CI, where you have an OIDC provider and no stable secret to manage; use a keypair or KMS when you must sign without reaching Fulcio, need air-gapped verification, or your compliance story requires a persistent, auditable signing identity you control.
Install
Binary releases for Linux, macOS and Windows are on the GitHub releases page; Homebrew, Arch, Nix, GitHub Action and Kubernetes installs are covered by the installation docs. For image-based builds, the README shows pinning the tool into a build stage:
FROM ghcr.io/sigstore/cosign/cosign:v2.4.1 as cosign-bin
FROM cgr.dev/chainguard/static:latest
COPY --from=cosign-bin /ko-app/cosign /usr/local/bin/cosign
ENTRYPOINT [ "cosign" ]
Cosign’s own support policy in the README: use a recent release - the project supports the most recent release plus the last release in the v2 series.
Sign a pushed image
The single most important rule, straight from the Cosign quickstart and README: always sign by digest, not by tag. A tag is mutable; signing :latest can sign something you did not intend.
$ docker build -t registry.example.com/payments/api:1.4.2 .
$ docker push registry.example.com/payments/api:1.4.2
$ DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' registry.example.com/payments/api:1.4.2)
$ echo $DIGEST
registry.example.com/payments/api@sha256:87ef60f558bad79beea6425a3b28989f01dd417164150ab3baab98dcbf04def8
Keyless signing:
$ cosign sign $DIGEST
Generating ephemeral keys...
Retrieving signed certificate...
Note that there may be personally identifiable information associated with this signed artifact.
This may include the email address associated with the account with which you authenticate.
By typing 'y', you attest that you grant (or have permission to grant) and agree to have this information stored permanently in transparency logs.
Are you sure you would like to continue? [y/N] y
Your browser will now be opened to:
https://oauth2.sigstore.dev/auth/auth?...&scope=openid+email...
Successfully verified SCT...
tlog entry created with index: 12086900
Pushing signature to: registry.example.com/payments/api
The browser step is your OIDC login (Google, GitHub or Microsoft per the quickstart). In a headless CI job you pre-authorize with cosign login / the GitHub Actions OIDC token - the Cosign installer action exists precisely for this, and Cosign integrates natively with GitHub and GitLab.
With a key:
$ COSIGN_PASSWORD=... cosign sign --key cosign.key $DIGEST
Pushing signature to: registry.example.com/payments/api:sha256-87ef60f558...f04def8.sig
Where the signature lands: Cosign stores it as an OCI object in the registry, in the same repository by default, under a tag derived from the digest - reg.example.com/ubuntu:sha256-<digest-with-colons-and-at-signs-replaced>.sig. Point it elsewhere with COSIGN_REPOSITORY if your registry policy requires a separate signature repository.
Two lifecycle caveats from the README worth knowing before you rely on this in production: the signature references its image only weakly, so signatures are not garbage-collected when the image is deleted, and they can be copied between environments without the image. Also, concurrent signature writes are a read-append-write race where the last write wins.
Verify in CI
Keyless verification needs the expected identity and issuer:
$ cosign verify $DIGEST \
--certificate-identity=ci@redlinesoft.net \
--certificate-oidc-issuer=https://accounts.google.com
Regex variants - --certificate-identity-regexp and --certificate-oidc-issuer-regexp - are available when an exact match is impractical (many workflow files, for example). For GitHub Actions the identity is the workflow URL and the issuer is the Actions token endpoint, as shown in the README’s blob example:
$ cosign verify-blob artifact \
--bundle artifact.sigstore.json \
--certificate-identity "https://github.com/ORG/REPO/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com"
For a signed image, the analogous check in a release workflow:
- name: Verify image signature
run: |
cosign verify "$IMAGE@$DIGEST" \
--certificate-identity "https://github.com/$GITHUB_REPOSITORY/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
| jq -e .
Key-based verification returns 0 if at least one Cosign-format signature matches the public key, and prints the signed payloads as JSON - the payload includes the container’s manifest digest, which is how detached signatures provably cover the right image:
$ cosign verify --key cosign.pub $DIGEST
The following checks were performed on these signatures:
- The cosign claims were validated
- The signatures were verified against the specified public key
Verify at deploy
Signing only pays off if something refuses unsigned images. Three points where to put the gate:
- Admission. On Kubernetes, Sigstore’s Policy Controller admits only images whose signatures match a defined policy (expected identity/issuer, key, or attestations) - so verification happens on every pod creation, not just when a human remembers.
- Deploy script. In a deployment pipeline,
cosign verifybeforekubectl applyordocker compose pullis a two-line gate. - Registry side. Some registries can enforce signatures on pull; Cosign’s registry support list covers Docker Hub, GHCR, ECR, ACR, Artifact Registry, Harbor, Nexus, Quay and others, so most teams can keep signatures alongside images in the registry they already use.
Whatever the enforcement point, make the policy strict on identity. --certificate-identity-regexp=.* verifies that someone signed - which proves much less than verifying that your release workflow did.
Attach an SBOM or attestation
A signature proves origin; an attestation carries a verifiable claim about the artifact - provenance, an SBOM, a test result. Cosign has built-in in-toto support (payloads signed with the DSSE spec):
$ cosign attest --predicate sbom.spdx.json --type spdxjson $DIGEST
$ cosign verify-attestation --key cosign.pub $DIGEST
The Docker side complements this: BuildKit can generate and attach an SBOM during the build itself.
$ docker buildx build --tag registry.example.com/payments/api:1.4.2 \
--attest type=sbom --push .
(--sbom=true is the shorthand.) Docker documents that these SBOMs follow the SPDX standard and attach to the final image as a JSON-encoded SPDX document using the in-toto SPDX predicate; the default generator is the BuildKit Syft scanner plugin built on Anchore Syft. Inspect what actually landed:
$ docker buildx imagetools inspect registry.example.com/payments/api:1.4.2 \
--format "{{ json .SBOM.SPDX }}"
Two scope details from that page that trip people up: by default BuildKit only scans the final stage, so build-time dependencies in earlier stages of a multi-stage build are invisible - set ARG BUILDKIT_SBOM_SCAN_STAGE=true (and ARG BUILDKIT_SBOM_SCAN_CONTEXT=true to include the build context). And you can validate before pushing by exporting to the local filesystem, which writes sbom.spdx.json to the output directory.
The pattern that holds up: sign the image, then attach attestations against the same digest, and verify both at deploy.
Common failure modes
From Cosign’s own troubleshooting section and the practical edges of the flow:
failed to verify timestamps: threshold not met for verified log entry integrated timestamps: 0 < 1- you are verifying a signature that requires RFC3161 timestamp support. Upgrade to the most recent Cosign; with Cosign 2.6.x, use--use-signed-timestamps.no signatures found- possibly an image signed against a Rekor v2 transparency log; upgrade Cosign. Before blaming the log, check the mundane causes: signed by digest but verifying by tag, or signatures in a different repo (look atCOSIGN_REPOSITORY).- Signing fails with HTTP errors - signing depends on multiple Sigstore services (OIDC provider, Fulcio, Rekor). Retrying is a legitimate workaround when a service blips.
- Verification identity mismatch - the most common policy bug. The certificate subject is the OIDC subject (an email or a workflow URL), not the registry namespace. Copy it from the signing log rather than guessing.
- Air-gapped verification - keyless signing embeds a bundle as a manifest annotation by default, so
cosign verify --offline --local-imagecan work with a trusted root, but the README warns that without TUF you must maintain your own up-to-date copy oftrusted_root.json. Keys sidestep this entirely. - Legacy registries - registries without full OCI media type support may need
COSIGN_DOCKER_MEDIA_TYPES=1for signing.
Checklist
- You sign by digest (
image@sha256:...), never by tag. - CI signs keyless via OIDC (Fulcio + Rekor); long-lived keys, if any, live in a KMS rather than a repo secret.
- Every verify call pins
--certificate-identityand--certificate-oidc-issuer(or--key), with no.*catch-alls in production. - Verification runs at least twice: on release in CI, and again at deploy/admission.
- SBOM/provenance is generated during build (
--attest type=sbom) and attached as an attestation to the same digest. - Multi-stage builds set
BUILDKIT_SBOM_SCAN_STAGEso build-time dependencies appear in the SBOM. - Cosign is on a currently supported release; timestamp and Rekor v2 issues are handled by upgrading, not by disabling verification.
- Signature retention is understood:
.sigobjects are not deleted with their images.
Sources: Sigstore documentation - Overview, Sigstore Quickstart with Cosign, Sigstore Cosign Key Management Overview, sigstore/cosign on GitHub, Docker docs - SBOM attestations