Signing, verifying and promoting by digest

Cosign v3 key-based and keyless signatures, verify-before-run, SBOM attestations and promotion by digest.

Advanced18 min · lesson 9 of 24
Lesson files
The scripts, test data and local test servers this lesson uses, exactly as they ran on the lab machine (3 files, 2 KB): signing.tar.gz. The lab VM shares no folders with your computer, so fetch them inside the VM: cd ~/lab && curl -fsSLO https://secopslog.com/lab-files/docker-int/signing.tar.gz && tar -xzf signing.tar.gz, which creates ~/lab/signing/. SHA-256: 231dbc374a32e491b3e6bde13181364f4af3d109962be3073363babe1d571017

A production host deploys with docker pull registry.example.com/app:1.4 && docker run .... Nothing in that command can tell whether app:1.4 is the image the pipeline built, tested and scanned, or one somebody pushed with a leaked registry token an hour ago. The registry accepted both pushes without asking. A registry stores images; it does not approve them. Approval has to be something a deploy can check, such as a signature over the image digest, made after the image passed its tests by a key or identity the deploy trusts.

This lesson runs the whole release order end to end with Cosign v3 and two local registries, proves that a verify-before-run script refuses an unsigned image and a modified one, promotes the signed digest, and verifies real keyless signatures from public projects. Use the main lab VM. The lesson files go in ~/lab/signing: app/Dockerfile, install-tools.sh (from "Pinning, SBOMs, provenance and scanning") and verify-run.sh. The lab needs internet access to GitHub, gcr.io and Sigstore's public services.

The release order

Release order for one image
1Build
candidate, local only
2Test
run the candidate
3Scan
gate on fixable findings
4Push
build registry
5Resolve digest
from the push result
6Attest
SBOM and provenance on the digest
7Sign
the digest, never a tag
8Verify
trusted key or identity
9Promote / deploy
copy or run that digest
Every step after Push names the image by the digest the push returned. A tag is resolved once, at Push, and never trusted again.

Tests and scans run on the candidate before anything leaves the build machine, so a failing build never reaches a registry where someone could deploy it. After the push the registry holds the image under the digest it will be known by everywhere, and every later step uses that digest: attestations and the signature are bound to it, verification checks it and promotion copies it. Signing before the push, or signing a tag, signs whatever the name resolves to at that moment, which is not necessarily what was tested.

Use separate repositories for separate trust levels. The build registry receives every CI build and keeps it for a short time, and staging deploys from it by digest. Production pulls only from a registry (or repository) that the promotion job alone can write to, and every production host verifies before it runs. In the lab, localhost:5000 is the build registry and localhost:5001 is production. "Docker in CI/CD: build to promote" in Docker in depth puts this order into a CI workflow.

Verify, then run

Docker Engine runs any image it is asked to run. It has no step that checks a signature before docker run, so on plain Docker hosts verification is a script in front of the run. This one resolves the reference to a digest once, verifies a Cosign signature on that digest and runs exactly that digest:

verify-run.sh
#!/bin/sh
# Usage: ./verify-run.sh IMAGE[:TAG|@DIGEST] [COMMAND...]
# Resolves IMAGE to one digest, verifies a Cosign signature on that digest, and only then runs
# that digest. Exits 1 without running anything when verification fails.
# COSIGN_VERIFY is the trust policy, and there is no default: an unset policy is an error, so a copy
# of this script can never fall back to a weaker one. Examples:
# keyless: COSIGN_VERIFY="--certificate-identity=URI --certificate-oidc-issuer=URL"
# key: COSIGN_VERIFY="--key /etc/cosign/release.pub" (an absolute path, never the working directory)
# this lab: COSIGN_VERIFY="--key $HOME/lab/signing/cosign.pub --insecure-ignore-tlog=true"
set -eu
: "${COSIGN_VERIFY:?set COSIGN_VERIFY to the trust policy (see the comments in this script)}"
ref=$1; shift
name=${ref%%@*}
case ${name##*/} in *:*) name=${name%:*} ;; esac
digest=$(docker buildx imagetools inspect "$ref" --format '{{.Manifest.Digest}}')
image=$name@$digest
# COSIGN_VERIFY is a list of flags, so it is expanded unquoted on purpose.
if ! err=$(cosign verify $COSIGN_VERIFY "$image" 2>&1 >/dev/null); then
echo "REFUSED $image"
printf '%s\n' "$err" | grep -m1 '^Error' | sed 's/^/ /'
exit 1
fi
echo "VERIFIED $image"
exec docker run --rm "$image" "$@"

Running the digest instead of the name closes the gap between check and use: a tag can move between cosign verify and docker run, a digest cannot, and Docker checks pulled content against the digest. COSIGN_VERIFY is the trust policy, and the script has no default: with the variable unset it stops with an error, so a copy on another host can never fall back to a weaker policy. For keyless signatures the policy names a certificate identity and issuer; for a key it names the public key by an absolute path, so a cosign.pub in whatever directory the script runs from is never trusted by accident. The lab sets a policy for its own key in the next section, and that policy skips the transparency log because the lab signs without one.

Tools, registries and a key pair

Install Cosign, Syft and Trivy with the same checksum-verified script as in the previous lesson:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ ./install-tools.sh cosign syft trivy export PATH="$HOME/lab/bin:$PATH" cosign version --json | jq -r '"cosign \(.gitVersion)"'; syft version | grep '^Version'; trivy --version | head -1
cosign-linux-arm64: OK syft_1.54.1_linux_arm64.tar.gz: OK trivy_0.75.0_Linux-ARM64.tar.gz: OK cosign v3.1.3 Version: 1.54.1 Version: 0.75.0

If the connection to GitHub is reset during a download, curl prints a curl: (35) line and --retry in the script tries again. A file that still arrived damaged would fail its checksum line.

Start the build registry on port 5000 and the production registry on port 5001. Both are plain registry:3 containers; what makes one of them production is who may push to it.

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ docker run -d --name lab-registry -p 127.0.0.1:5000:5000 registry:3 docker run -d --name lab-prod-registry -p 127.0.0.1:5001:5000 registry:3
d6c9a215a647e2b14ba3ec555e20ddbd3c0a27f34817865681c8000d7b694f70 3ba73cef203fe1b2b5293bbcb5426bddbacc6d3910545007c2fd49fe5e0ac807

A key pair is the simplest trust anchor: whoever holds the private key can sign, and anyone with the public key can verify. The export COSIGN_VERIFY=... line in the next command sets the trust policy verify-run.sh uses for the rest of the lesson; run it again in any new shell, together with the PATH and COSIGN_PASSWORD exports. cosign generate-key-pair encrypts the private key with the password in COSIGN_PASSWORD, here an obvious test value. In CI the password comes from the secret store, or the key lives in a KMS and never touches disk (--key awskms://..., gcpkms://, azurekms://, hashivault://).

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ export COSIGN_PASSWORD=lab-only-test-password cosign generate-key-pair export COSIGN_VERIFY="--key $HOME/lab/signing/cosign.pub --insecure-ignore-tlog=true" cosign signing-config create --out lab-signing-config.json cat lab-signing-config.json; echo
Private key written to cosign.key Public key written to cosign.pub {"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json", "rekorTlogConfig":{}, "tsaConfig":{}}

Cosign v3 takes the Sigstore services it uses (Fulcio, Rekor, a timestamp authority) from a signing config. The default comes from Sigstore's TUF repository and uploads even key-based signatures to the public Rekor transparency log. lab-signing-config.json lists no services, so the lab publishes nothing about throwaway keys and a localhost registry. The price is that verification must skip the log (--insecure-ignore-tlog=true) and nothing independent records when a signature was made. In production, sign keyless (below) or keep a log, public or a private Rekor. The older --tlog-upload=false is deprecated, and v3 refuses it while a signing config is in use.

Build, test and scan the candidate

app/Dockerfile
# alpine:3.22, pinned to the index digest the tag pointed at when this lab was written
FROM alpine:3.22@sha256:5291449c3df73caf6ed85e649dec1b9e818b39a5d8c871e97afc13e9cd5e8fa8
ARG BUILD=1
RUN echo "lab-app build $BUILD" > /build.txt
USER 10001
CMD ["cat", "/build.txt"]
ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ docker build -q --build-arg BUILD=1 -t lab-app:candidate app
sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e
$ out=$(docker run --rm lab-app:candidate) [ "$out" = "lab-app build 1" ] && echo "test passed: $out"
test passed: lab-app build 1
$ trivy image -q --severity HIGH,CRITICAL --ignore-unfixed --exit-code 1 --format json -o scan.json lab-app:candidate echo "trivy exit status: $?" jq -r '.Results[] | "\(.Target): \(.Vulnerabilities // [] | length) HIGH/CRITICAL with a fix"' scan.json
trivy exit status: 0 lab-app:candidate (alpine 3.22.6): 0 HIGH/CRITICAL with a fix

lab-app:candidate exists only in the local image store. The test compares the output with what this build must print, and the scan applies the gate from the previous lesson. Trivy downloads its database on this first run. The Alpine base has no fixable HIGH or CRITICAL findings today, so the gate passes; on another day it may not, and then the release stops here.

Push and resolve the digest

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ docker tag lab-app:candidate localhost:5000/lab-build/app:1.0 docker push localhost:5000/lab-build/app:1.0 | tee push.log sed -n 's/^1\.0: digest: \(sha256:[0-9a-f]\{64\}\) .*/\1/p' push.log > app.digest cat app.digest
The push refers to repository [localhost:5000/lab-build/app] 46b3f045b8b7: Pushed 538e0c442225: Pushed 44136fa355b3: Pushed 16fc4f52163f: Pushed 1.0: digest: sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e size: 855 sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e

The 1.0: digest: line of the push output is the digest the registry stored for the tag, the index digest on Docker 29, and sed saves it in app.digest. Do not ask the registry what 1.0 points to after the push: another job could push the same tag in between, and you would sign its image. With docker buildx build --push the digest comes from --metadata-file, and docker/build-push-action returns it as the digest output.

Attest and sign the digest

An attestation is a signed statement about the image: an in-toto statement whose subject is the digest and whose predicate is a document, here the SBOM. BuildKit's attestations from the previous lesson sit inside the image index and are not signed; cosign attest signs one with your key, so a verifier knows who vouches for it. Generate the SBOM from the pushed digest, attest it, then sign:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ syft scan -q registry:localhost:5000/lab-build/app@$(cat app.digest) -o spdx-json=sbom.spdx.json cosign attest --yes --key cosign.key --signing-config lab-signing-config.json \ --type spdxjson --predicate sbom.spdx.json localhost:5000/lab-build/app@$(cat app.digest)
Using payload from: sbom.spdx.json Signing artifact...
$ cosign sign --yes --key cosign.key --signing-config lab-signing-config.json \ localhost:5000/lab-build/app@$(cat app.digest)
Signing artifact... Pushing signature to: localhost:5000/lab-build/app
$ curl -s http://localhost:5000/v2/lab-build/app/tags/list; echo cosign tree localhost:5000/lab-build/app@$(cat app.digest)
{"name":"lab-build/app","tags":["1.0","sha256-c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e"]} 📦 Supply Chain Security Related artifacts for an image: localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e └── 🔗 https://spdx.dev/Document artifacts via OCI referrer: localhost:5000/lab-build/app@sha256:08cd665496c0f9505cfadea0ab443e5cc1ee43e899486f60589d3aaf3eb6779f └── 🍒 sha256:763c88b4c6bb0bfd761ea28b3bbc1c04cbb0c5f4b8973aae5f262b5736324ecd └── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: localhost:5000/lab-build/app@sha256:e56215c637c4f6995d9cafe78615086066605d15fe608a7b0bd373587067c73b └── 🍒 sha256:0cdb3dc46db5d87ca568152e3f3057a403a6e4ed295750197c391abd8b495212

Cosign v3 stores each signature and attestation as a Sigstore bundle, an OCI artifact that refers to the image digest. A registry with the OCI 1.1 referrers API returns such artifacts when asked for the referrers of a digest. registry:3 lacks that API, so Cosign used the fallback the OCI specification defines: a tag named after the digest, sha256-<hex>, whose index lists both bundles, the signature (cosign/sign/v1) and the SBOM attestation (spdx.dev/Document). The image index and its digest are unchanged.

Verify

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ cosign verify --key cosign.pub --insecure-ignore-tlog=true \ localhost:5000/lab-build/app@$(cat app.digest) > verify.json jq -r '.[].critical | "\(.type) \(.image["docker-manifest-digest"])"' verify.json
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature. Verification for localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e -- The following checks were performed on each of these signatures: - The cosign claims were validated - Existence of the claims in the transparency log was verified offline - The signatures were verified against the specified public key https://spdx.dev/Document sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e https://sigstore.dev/cosign/sign/v1 sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e
$ cosign verify-attestation --key cosign.pub --insecure-ignore-tlog=true --type spdxjson \ localhost:5000/lab-build/app@$(cat app.digest) 2>/dev/null | jq -r '.payload | @base64d | fromjson | "predicate: \(.predicateType)", "subject: sha256:\(.subject[0].digest.sha256)", "packages: \(.predicate.packages | length)"'
predicate: https://spdx.dev/Document subject: sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e packages: 17

cosign verify checked each signature against cosign.pub and that the signed claims name this digest. It lists every bundle this key signed for the digest, so the attestation appears next to the signature; type tells them apart. The summary says the log was "verified offline" although the warning above says it was skipped; trust the warning. verify-attestation returns the signed statement, which decodes to the SBOM for this digest. A deploy policy that requires an attestation of a given type (an SBOM, a scan result, a VEX document) is built on this command.

Now the script. It runs the signed image:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ ./verify-run.sh localhost:5000/lab-build/app:1.0
VERIFIED localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e lab-app build 1

Someone builds a "hotfix" and pushes it straight to the production registry, bypassing the pipeline. The push works, because the registry checks credentials, not provenance. The script does not run it:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ docker build -q --build-arg BUILD=hotfix -t localhost:5001/lab-prod/app:1.0 app docker push -q localhost:5001/lab-prod/app:1.0 ./verify-run.sh localhost:5001/lab-prod/app:1.0
sha256:2d6f66cc3a9020012f23bc5386fe7b840d69c7811b4b0dcd03df1cb78a5a6053 localhost:5001/lab-prod/app:1.0 REFUSED localhost:5001/lab-prod/app@sha256:2d6f66cc3a9020012f23bc5386fe7b840d69c7811b4b0dcd03df1cb78a5a6053 Error: no signatures found

A second case: someone rebuilds the image, pushes it over 1.0 in the build registry and signs it with a key of their own, so the tag now points at a signed image:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ cosign generate-key-pair --output-key-prefix other 2>/dev/null docker build -q --build-arg BUILD=2 -t localhost:5000/lab-build/app:1.0 app docker push -q localhost:5000/lab-build/app:1.0 cosign sign --yes --key other.key --signing-config lab-signing-config.json \ localhost:5000/lab-build/app@$(docker buildx imagetools inspect localhost:5000/lab-build/app:1.0 --format '{{.Manifest.Digest}}') 2>/dev/null ./verify-run.sh localhost:5000/lab-build/app:1.0
sha256:e5e3cf58f3e5d6d306ce56bd2b5a0bb9bcb33e1f59de1e5faee6ec08e7d7e54c localhost:5000/lab-build/app:1.0 REFUSED localhost:5000/lab-build/app@sha256:e5e3cf58f3e5d6d306ce56bd2b5a0bb9bcb33e1f59de1e5faee6ec08e7d7e54c Error: no matching attestations: failed to verify signature: could not verify envelope: accepted signatures do not match threshold, Found: 0, Expected 1

The tag resolved to the new digest, whose only signature was made with other.key. Cosign v3 says "no matching attestations" for signature bundles too; the important part is "Found: 0, Expected 1", no signature from the trusted key. A check for "any valid signature" would have passed, which is why a signature means nothing without the key or identity you require. The tested image is still reachable by the recorded digest, and that still verifies:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ ./verify-run.sh localhost:5000/lab-build/app@$(cat app.digest)
VERIFIED localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Unable to find image 'localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e' locally localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e: Pulling from lab-build/app Digest: sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Status: Downloaded newer image for localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e lab-app build 1

Docker had no local image under that digest reference, so it pulled one; every layer was already in the local store, and the content was checked against the digest.

Promote by digest

Promotion copies the verified digest from the build registry to production with imagetools create, as in "Tags, digests and promotion" in Docker in depth. Then verify in production:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ docker buildx imagetools create --tag localhost:5001/lab-prod/app:1.0 \ localhost:5000/lab-build/app@$(cat app.digest) ./verify-run.sh localhost:5001/lab-prod/app:1.0
#1 [internal] pushing localhost:5001/lab-prod/app #1 0.000 copying sha256:8c2e8431768e6e7f4ef03211af5c4ea1b40c076e5a5db13ff44dc086decb48ca from localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e to localhost:5001/lab-prod/app #1 0.000 copying sha256:92eafa6a0a8ef285e08d908d5ea4d33701235d9b8aee54271b99d53bfbb64c0f from localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e to localhost:5001/lab-prod/app #1 0.047 pushing sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e to localhost:5001/lab-prod/app:1.0 #1 DONE 0.1s REFUSED localhost:5001/lab-prod/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Error: no signatures found

The image arrived with the same digest, but the signature did not. imagetools create copies the index and everything it references; the signature bundles reference the image, not the other way round, so nothing in the index leads to them. Copy them as well. With the tag fallback they are one more tag to copy:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ sig=sha256-$(cut -d: -f2 app.digest) docker buildx imagetools create --tag localhost:5001/lab-prod/app:$sig localhost:5000/lab-build/app:$sig ./verify-run.sh localhost:5001/lab-prod/app:1.0
#1 [internal] pushing localhost:5001/lab-prod/app #1 0.000 copying sha256:08cd665496c0f9505cfadea0ab443e5cc1ee43e899486f60589d3aaf3eb6779f from localhost:5000/lab-build/app:sha256-c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e to localhost:5001/lab-prod/app #1 0.000 copying sha256:e56215c637c4f6995d9cafe78615086066605d15fe608a7b0bd373587067c73b from localhost:5000/lab-build/app:sha256-c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e to localhost:5001/lab-prod/app #1 0.053 pushing sha256:a60fa67a4fd99e90085865fa680013c99e13b74100df4d09bc718dacafc98fa3 to localhost:5001/lab-prod/app:sha256-c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e #1 DONE 0.1s VERIFIED localhost:5001/lab-prod/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Unable to find image 'localhost:5001/lab-prod/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e' locally localhost:5001/lab-prod/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e: Pulling from lab-prod/app Digest: sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Status: Downloaded newer image for localhost:5001/lab-prod/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e lab-app build 1

The signature covers the digest, not a registry name, so it verifies wherever the digest and its signature artifacts are. Registries with the referrers API have no fallback tag to copy; use a tool that copies referrers with the image, such as oras copy -r or regctl image copy --referrers (cosign copy is deprecated in v3, and its help points to oras copy -r). The other common pattern is to sign again after promotion with a production key that only the promotion job holds, so the second signature means "approved for production" rather than "built by CI".

Keyless signing

Keys have to be stored, rotated and revoked. Keyless signing replaces the key with an identity. The CI job requests an OpenID Connect (OIDC) token from its platform, a signed statement such as "workflow release.yaml in repository org/app, running for tag v1.4". Cosign generates a throwaway key pair, and Fulcio, Sigstore's certificate authority, issues a ten-minute certificate binding that public key to the identity. Cosign signs the digest, Rekor records the signature with a timestamp in a public append-only log, and the private key is discarded. A verifier checks that the certificate chains to Fulcio's root (distributed through TUF), that the signature was logged while the certificate was valid, and that identity and issuer match what it expects. Public Fulcio accepts tokens only from configured issuers (GitHub Actions, GitLab.com, Google and others); a self-hosted GitLab needs a private Sigstore deployment.

Keyless signing needs a real OIDC identity, so the lab verifies signatures that public projects made. Google's distroless images are signed with a Google service account identity, documented in the distroless README:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ d=$(docker buildx imagetools inspect gcr.io/distroless/static-debian13:nonroot --format '{{.Manifest.Digest}}') echo "nonroot -> $d" cosign verify gcr.io/distroless/static-debian13@$d \ --certificate-identity keyless@distroless.iam.gserviceaccount.com \ --certificate-oidc-issuer https://accounts.google.com 2>/dev/null | jq -r '.[0].optional | "subject: \(.Subject)\nissuer: \(.Issuer)\nrekor: log index \(.Bundle.Payload.logIndex), logged \(.Bundle.Payload.integratedTime | todate)"'
nonroot -> sha256:e2e927ec666bae08560abb3c55d0659eceabb657f56b6782ab500a9fc7f555e3 subject: keyless@distroless.iam.gserviceaccount.com issuer: https://accounts.google.com rekor: log index 2822616414, logged 2026-09-13T22:01:12Z
$ d=$(docker buildx imagetools inspect gcr.io/distroless/static-debian13:nonroot --format '{{.Manifest.Digest}}') cosign verify gcr.io/distroless/static-debian13@$d \ --certificate-identity release@example.com \ --certificate-oidc-issuer https://accounts.google.com > /dev/null
Error: no matching signatures: none of the expected identities matched what was in the certificate, got subjects [keyless@distroless.iam.gserviceaccount.com] with issuer https://accounts.google.com error during command execution: no matching signatures: none of the expected identities matched what was in the certificate, got subjects [keyless@distroless.iam.gserviceaccount.com] with issuer https://accounts.google.com

Verification is by digest again. Subject and issuer come from the Fulcio certificate, and the Rekor entry shows when the signature was logged. With a different expected identity the same signature is rejected, and the error names the identity it found. Pin the identity exactly: a loose --certificate-identity-regexp '.*' accepts a signature from anyone the issuer will vouch for, which for GitHub Actions means any workflow in any repository. If you need a pattern, anchor it (^https://github\.com/org/app/\.github/workflows/release\.yaml@refs/tags/v).

Signatures from GitHub Actions carry the workflow file and Git ref as identity. Trivy's release checksums are signed that way, which also completes the install from the previous lesson: the checksum file the hashes in install-tools.sh were taken from can itself be verified:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ u=https://github.com/aquasecurity/trivy/releases/download/v0.75.0 curl -fsSLO --retry 10 --retry-delay 5 --retry-all-errors $u/trivy_0.75.0_checksums.txt curl -fsSLO --retry 10 --retry-delay 5 --retry-all-errors $u/trivy_0.75.0_checksums.txt.sigstore.json cosign verify-blob trivy_0.75.0_checksums.txt --bundle trivy_0.75.0_checksums.txt.sigstore.json \ --certificate-identity https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v0.75.0 \ --certificate-oidc-issuer https://token.actions.githubusercontent.com
Verified OK

The identity says which workflow on which ref signed, not that the code was benign. In the March 2026 Trivy compromise the attacker pushed a commit that altered the release workflow and tagged it v0.69.4, and the project's own release pipeline built and published the malicious release from that tag. Protected tags and branches and review of workflow changes decide whether a ref deserves trust. In CI, signing is cosign sign --yes IMAGE@DIGEST with no key, plus permission to request an OIDC token (id-token: write on GitHub Actions); "Docker in CI/CD: build to promote" in Docker in depth shows the workflow.

Where verification can be enforced

Docker Engine itself never enforces a signature. Docker Content Trust, the Notary v1 mechanism behind DOCKER_CONTENT_TRUST=1 and docker trust, was removed from the Docker CLI in 29.0:

ubuntu@secopslog-docker:~ · Docker 29.8.2
$ DOCKER_CONTENT_TRUST=1 docker pull -q alpine:3.22 docker trust inspect alpine:3.22
docker.io/library/alpine:3.22 docker: unknown command: docker trust Run 'docker --help' for more information
# Docker Content Trust was removed from the CLI in Docker 29.0: the variable is ignored and the command no longer exists.

The variable is silently ignored and the pull succeeds. On Docker hosts the enforcement points are a wrapper like verify-run.sh, a deploy job that verifies before it calls Docker, and a production registry that only a verifying promotion job can write to. Engine authorization plugins can veto API calls, but none that ships with Docker checks Sigstore signatures. Kubernetes has the step Docker lacks: an admission controller sees every pod before it is created, and Kyverno or Sigstore's policy-controller reject pods whose images lack a valid signature or attestation from the required identity.

Notation, from the CNCF Notary Project, is the other current signing tool. It signs digests with X.509 certificates from your own PKI (often a cloud key vault), stores signatures as OCI referrers and verifies against a trust policy file. Cosign fits teams that want keyless CI identities and a public log; Notation fits organisations that already run certificate authorities. Both sign digests, both need verification wired into the deploy path, and Kubernetes admission controllers support both.

Clean up

Remove the registries, the images (including the digest references the runs created), the keys and generated files, the tools and their caches:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.8.2
$ docker rm -f -v lab-registry lab-prod-registry docker image rm lab-app:candidate localhost:5000/lab-build/app:1.0 localhost:5001/lab-prod/app:1.0 \ localhost:5000/lab-build/app@$(cat app.digest) localhost:5001/lab-prod/app@$(cat app.digest) rm -f cosign.key cosign.pub other.key other.pub lab-signing-config.json app.digest push.log scan.json \ sbom.spdx.json verify.json trivy_0.75.0_checksums.txt trivy_0.75.0_checksums.txt.sigstore.json rm -rf ~/lab/bin ~/.cache/trivy ~/.sigstore
lab-registry lab-prod-registry Untagged: lab-app:candidate Untagged: localhost:5000/lab-build/app:1.0 Deleted: sha256:e5e3cf58f3e5d6d306ce56bd2b5a0bb9bcb33e1f59de1e5faee6ec08e7d7e54c Untagged: localhost:5001/lab-prod/app:1.0 Deleted: sha256:2d6f66cc3a9020012f23bc5386fe7b840d69c7811b4b0dcd03df1cb78a5a6053 Untagged: localhost:5000/lab-build/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Untagged: localhost:5001/lab-prod/app@sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e Deleted: sha256:c3345308660beb3ba00779cc541fd9aea3ddb669a294c898a30b52d8bf53752e
Quick check
01A deploy script runs cosign verify --key cosign.pub registry.example.com/app:1.4 and, if that succeeds, docker run registry.example.com/app:1.4. What is wrong with it?
Incorrect — Cosign resolves a tag and verifies the digest it finds; it works, which is why the flaw is easy to miss.
Incorrect — Docker Engine does not verify signatures at all; Content Trust was removed in 29.0.
Correct — Resolve the tag once, verify that digest, and run the digest, as verify-run.sh does.
Incorrect — A local, trusted copy of the public key is exactly what you want; fetching it from the same registry would let an attacker replace it.
02After docker buildx imagetools create --tag prod.example.com/app:1.4 build.example.com/app@sha256:<d>, cosign verify against production fails with "no signatures found", while the same check against the build registry passes. Why?
Correct — Copy the signature artifacts too (the sha256-<hex> tag, or referrers with oras or regctl), or sign again in production.
Incorrect — The digest is unchanged by a copy; the lab verified the same digest in production once the signature was copied.
Incorrect — Cosign signs the digest; the copied signature verified in the second registry.
Incorrect — The lab verified in a registry:3 without that API, using the fallback tag.
03A pipeline verifies with cosign verify --certificate-identity-regexp '.*' --certificate-oidc-issuer https://token.actions.githubusercontent.com ghcr.io/org/app@sha256:<d>. It passes. What has been established?
Incorrect — The regexp matches any identity, so the workflow and repository were never checked.
Incorrect — A signature says who signed a digest, not what the image contains; that is the job of scans and attestations.
Incorrect — Keyless signatures are verified with the Fulcio certificate and the Rekor entry; no private key is involved.
Correct — Anyone can run a workflow that gets a Fulcio certificate. Pin the exact identity, or an anchored pattern for the workflow and refs you trust.

Try this

Work through “Clean up” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.

Takeaway

If you keep one thing from signing, verifying and promoting by digest, keep “Clean up”. Decide now which check you will run when this shows up on a live system, and write it somewhere your team will find it.

Related