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: a194a7ff295819f3d492b783a61c76a02a0d04f93c9d98b9e659e09984ff9a89

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

You'll run the whole release order end to end with Cosign v3 and two local registries. You'll prove that a verify-before-run script refuses an unsigned image and a modified one. Then you'll promote the signed digest and verify 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'll be known by everywhere. Every later step uses that digest. Attestations and the signature are bound to it, verification checks it and promotion copies it. If you sign before the push, or sign a tag, you sign whatever the name resolves to at that moment. That isn't necessarily what was tested.

Use separate repositories for separate trust levels. The build registry gets every CI build and keeps it for a short time. Staging deploys from it by digest. Production pulls only from a registry (or repository) that the promotion job alone can write to. Every production host checks 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 you ask it 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 can't, and Docker checks pulled content against it. 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. That way 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. 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-checked script you used in the previous lesson:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.9.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 curl: (35) Recv failure: Connection reset by peer curl: (35) Recv failure: Connection reset by peer curl: (35) Recv failure: Connection reset by peer curl: (35) Recv failure: Connection reset by peer syft_1.55.0_linux_arm64.tar.gz: OK trivy_0.76.1_Linux-ARM64.tar.gz: OK cosign v3.1.4 Version: 1.55.0 Version: 0.76.1

If GitHub resets the connection during a download, curl prints a curl: (35) line. Then --retry in the script tries again. A file that still came in 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.9.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
46187b5fe874e14b39222a0502a9a1d7e1a9575f99ab71f737ddaa0acd9f8a36 d9e743e2acd73d1a20a4511636323e0009651ee5356898ac53fb01a0be44ac32

A key pair is the simplest trust anchor. Whoever holds the private key can sign. If you have the public key, you 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.9.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. It 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.24, pinned to the index digest the tag pointed at when this lab was written
FROM alpine:3.24@sha256:be2656061b536c1883c4034790e21d0a09c5aaaf51968b89368a3c0371ea2c9a
ARG BUILD=1
RUN echo "lab-app build $BUILD" > /build.txt
USER 10001
CMD ["cat", "/build.txt"]
ubuntu@secopslog-docker:~/lab/signing · Docker 29.9.2
$ docker build -q --build-arg BUILD=1 -t lab-app:candidate app
sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031
$ 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.24.3): 0 HIGH/CRITICAL with a fix

lab-app:candidate exists only in your local image store. The test compares the output with what this build must print. 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.9.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] 740b950f7264: Pushed d3531b7a4f21: Pushed 44136fa355b3: Pushed fb5249218f22: Pushed 1.0: digest: sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 size: 855 sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031

The 1.0: digest: line of the push output is the digest the registry stored for the tag (the index digest on Docker 29). 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'd 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. It's 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 aren't 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.9.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-b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031"]} 📦 Supply Chain Security Related artifacts for an image: localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 └── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: localhost:5000/lab-build/app@sha256:27df2363757961e8f879a525059d093e7c7c51ba36e1b061d8d6c9b806dcad65 └── 🍒 sha256:9c093f0e47288074c3b09c78fa36045433350732f791be2e38f1021d3c5f732a └── 🔗 https://spdx.dev/Document artifacts via OCI referrer: localhost:5000/lab-build/app@sha256:fedc666c878b0a6578de5ad35accacafd8944de5d9b23e9ed685618e2c127498 └── 🍒 sha256:8b13c75ba294ff658522fa16be055800ca58cc83ffb3f89aa96b7c5bd50ed8b5

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>. Its 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.9.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:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 -- 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://sigstore.dev/cosign/sign/v1 sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 https://spdx.dev/Document sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031
$ 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:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 packages: 17

cosign verify checked each signature against cosign.pub, and checked 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. You'd build a deploy policy on this command when it requires an attestation of a given type (an SBOM, a scan result, a VEX document).

Now the script. It'll run the signed image:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.9.2
$ ./verify-run.sh localhost:5000/lab-build/app:1.0
VERIFIED localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 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 doesn't run it:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.9.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:cf8cad7a75077597677761915886e5d0e89b97231c9bea08370990d3ebd07519 localhost:5001/lab-prod/app:1.0 REFUSED localhost:5001/lab-prod/app@sha256:cf8cad7a75077597677761915886e5d0e89b97231c9bea08370990d3ebd07519 Error: no signatures found

Here's 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.9.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:3ad53f896be7f7b37b804f860c534a5a293aa4e462a5740707bf9d271849b283 localhost:5000/lab-build/app:1.0 REFUSED localhost:5000/lab-build/app@sha256:3ad53f896be7f7b37b804f860c534a5a293aa4e462a5740707bf9d271849b283 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. That's 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.9.2
$ ./verify-run.sh localhost:5000/lab-build/app@$(cat app.digest)
VERIFIED localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Unable to find image 'localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031' locally localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031: Pulling from lab-build/app Digest: sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Status: Downloaded newer image for localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 lab-app build 1

Docker had no local image under that digest reference, so it pulled one. Every layer was already in your local store, and Docker checked the content 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.9.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:fc8b0ac0c87cc2ba14d2ff334af812813fb5472bc8949b22057ef2717964e861 from localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 to localhost:5001/lab-prod/app #1 0.000 copying sha256:49e051862491c85511d487569d5be9c56a89a0ef198536f5d0351a97f1e3e8f8 from localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 to localhost:5001/lab-prod/app #1 0.054 pushing sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 to localhost:5001/lab-prod/app:1.0 #1 DONE 0.1s REFUSED localhost:5001/lab-prod/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Error: no signatures found

The image arrived with the same digest, but the signature didn't come with it. 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're one more tag to copy:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.9.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:fedc666c878b0a6578de5ad35accacafd8944de5d9b23e9ed685618e2c127498 from localhost:5000/lab-build/app:sha256-b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 to localhost:5001/lab-prod/app #1 0.000 copying sha256:27df2363757961e8f879a525059d093e7c7c51ba36e1b061d8d6c9b806dcad65 from localhost:5000/lab-build/app:sha256-b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 to localhost:5001/lab-prod/app #1 0.039 pushing sha256:412a83395bc5b28ac3dc2154a16b697bd8f20b886e25822dcfff184e09f0a7b4 to localhost:5001/lab-prod/app:sha256-b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 #1 DONE 0.1s VERIFIED localhost:5001/lab-prod/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Unable to find image 'localhost:5001/lab-prod/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031' locally localhost:5001/lab-prod/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031: Pulling from lab-prod/app Digest: sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Status: Downloaded newer image for localhost:5001/lab-prod/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 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. There, 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. You'd use a production key that only the promotion job holds. Then the second signature means "approved for production" rather than "built by CI".

Keyless signing

Keys have to be stored, rotated and revoked, and that's work you'd rather skip. Keyless signing swaps the key for an identity. The CI job requests an OpenID Connect (OIDC) token from its platform. That's a signed statement such as "workflow release.yaml in repository org/app, running for tag v1.4". Cosign generates a throwaway key pair. Fulcio, Sigstore's certificate authority, issues a ten-minute certificate binding that public key to the identity. Cosign signs the digest, and Rekor records the signature with a timestamp in a public append-only log. Then the private key is discarded.

A verifier checks three things. The certificate must chain to Fulcio's root (distributed through TUF). The signature must have been logged while the certificate was valid. And identity and issuer must 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 checks signatures that public projects made. Google signs its distroless images with a Google service account identity. The distroless README names it:

ubuntu@secopslog-docker:~ · Docker 29.9.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:18f4cc26c9ffa7f20a359298be7deadac5c607c8e03a02b8119f1a5fc2f82c99 subject: keyless@distroless.iam.gserviceaccount.com issuer: https://accounts.google.com rekor: log index 3427330340, logged 2026-10-23T22:06:17Z
$ 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

You verify by digest again. Subject and issuer come from the Fulcio certificate. 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. For GitHub Actions, that 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. That also completes the install from the previous lesson, since you can verify the checksum file that the hashes in install-tools.sh were taken from:

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

The curl: (35) lines are connection resets that --retry got past, and Verified OK confirms the checksum file came from Trivy's release workflow at tag v0.76.1. The identity says which workflow on which ref signed. It doesn't say the code was safe. In the March 2026 Trivy compromise, the attacker pushed a commit that altered the release workflow and tagged it v0.69.4. The project's own release pipeline then built and published the malicious release from that tag. Protected tags and branches, and review of workflow changes, decide whether you can trust a ref. 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 won't ever enforce 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.9.2
$ DOCKER_CONTENT_TRUST=1 docker pull -q alpine:3.24 docker trust inspect alpine:3.24
docker.io/library/alpine:3.24 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 just works. On Docker hosts, you have three enforcement points. There's a wrapper like verify-run.sh, and a deploy job that verifies before it calls Docker. And there's 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's created. 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). It stores signatures as OCI referrers and verifies against a trust policy file. Cosign suits you if you want keyless CI identities and a public log. Notation suits you if you 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 and the images, including the digest references the runs made. Then remove the keys, the files you made, the tools and their caches:

ubuntu@secopslog-docker:~/lab/signing · Docker 29.9.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.76.1_checksums.txt trivy_0.76.1_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:3ad53f896be7f7b37b804f860c534a5a293aa4e462a5740707bf9d271849b283 Untagged: localhost:5001/lab-prod/app:1.0 Deleted: sha256:cf8cad7a75077597677761915886e5d0e89b97231c9bea08370990d3ebd07519 Untagged: localhost:5000/lab-build/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Untagged: localhost:5001/lab-prod/app@sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031 Deleted: sha256:b8f2e2a71834268f8a48c452d7f4ebf267661004ef86cfb3f80d2a600920b031

Try this

Before the clean-up step, on the main lab VM in ~/lab/signing, run the script once with the trust policy removed: env -u COSIGN_VERIFY ./verify-run.sh localhost:5000/lab-build/app:1.0. Predict whether it resolves the tag or calls cosign at all, then read the : "${COSIGN_VERIFY:?...}" line in the listing and compare. Then, with the policy set again, run it on the production copy by digest, localhost:5001/lab-prod/app@$(cat app.digest). Predict whether it verifies now that the signature tag was copied, and compare with the promote-sig output above.

Remember

Check the digest you're about to run against the one key or identity you trust. Then run that same digest. The rebuilt image pushed over 1.0 carried a perfectly valid signature, and only the "Found: 0, Expected 1" check against the trusted key stopped it. The usual review miss is a loose --certificate-identity-regexp '.*' or an "any signature" check, which passes for whoever managed to sign.

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.

Related