Skip to content

Verify Docker image provenance

Severity: mediumApplies to: Docker Engine 27.x+Applies to: Docker Engine 29.x
The fixDockerfile / compose.yml
# Not this — the tag can be repointed at any time
FROM node:22-alpine
# This — the digest is the image, permanently
FROM node:22-alpine@sha256:9bcd4a8c0b1e9c9b1f2e1f8e0d3c4b5a6978859f0e1d2c3b4a5968778695a4b3
Terminal window
# DOCKER_CONTENT_TRUST=1 # retired in 2025 — do not use

Nearly every Docker hardening guide tells you to set DOCKER_CONTENT_TRUST=1. That advice is dead.

Docker retired Content Trust, with the retirement beginning 8 August 2025 as the oldest DCT signing certificates for Docker Official Images started expiring. It depended on the upstream Notary v1 server, which is no longer maintained. Docker’s own figure for its usage before retirement: fewer than 0.05% of Docker Hub image pulls.

So DOCKER_CONTENT_TRUST=1 is not a control you’re missing. It is a variable that does nothing useful and, once the certificates expire, mostly produces failures for images whose signatures can no longer be validated. Docker’s guidance is to move to Sigstore/Cosign or the Notary Project’s Notation.

If your CI sets it, remove it. If your hardening checklist lists it, the checklist predates August 2025 and everything else on it deserves a second look.

Digest pinning is the control that actually works

Section titled “Digest pinning is the control that actually works”

Before reaching for signatures, use the thing that requires no infrastructure at all.

A tag is a mutable pointer. node:22-alpine today and node:22-alpine tomorrow can be different images. That’s normally benign — it’s how you get patches — but it means your build is not reproducible, and a compromised registry account or a namespace takeover repoints the tag under you.

A digest is the image. @sha256:... is a content hash: it cannot be repointed, and Docker verifies it on pull. If the bytes don’t match the hash, the pull fails. That is integrity with no keys, no CA, and no transparency log.

The trade-off is real: pinned digests don’t get patches automatically. That’s what Dependabot and Renovate are for — both understand digest pins and will open a PR when the tag moves, which turns “silently updated” into “reviewed update”. That’s the version you want.

Terminal window
cosign sign --key cosign.key myregistry.io/myapp:1.4.2
cosign verify --key cosign.pub myregistry.io/myapp:1.4.2

Or keyless, which is the interesting part — no key to manage or leak:

Terminal window
cosign sign myregistry.io/myapp:1.4.2 # OIDC identity, ephemeral cert
cosign verify myregistry.io/myapp:1.4.2 \
--certificate-identity=ci@example.com \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com

Keyless signing uses your OIDC identity, gets a short-lived certificate from the Fulcio CA, and records the signature in the Rekor transparency log. There is no private key sitting in CI to steal.

Both Cosign and Notation store signatures alongside the image in any OCI-compliant registry — which is precisely what DCT couldn’t do, since it needed a separate Notary server. That’s why the ecosystem moved.

Signing only means something if something verifies. A signature nobody checks is decoration — the verification belongs in your deploy pipeline or an admission controller, not in a README.