Prove where your container image came from
- Advanced
- 25 min
- Published
- Verified
What you'll learn
- Explain why a tag such as
myorg/my-app:1.0is not evidence of what's running, and why a digest is - Generate a Cosign key pair and sign an image by digest with
cosign sign --key - Verify a signature with
cosign verify --key cosign.pub, and find it in the registry withcosign tree - Watch verification fail when someone pushes a different image over a signed tag
- Attach a signed attestation to an image with
cosign attest
Production runs myorg/my-app:1.0. During an incident review, someone asks
a simple question: is the container running in the cluster the one your team
built and reviewed? The tag can’t answer that. A tag is a name that the
registry lets anyone with push access move. A CI job with a leaked token, a
teammate testing a fix, or an attacker can push a different image as
myorg/my-app:1.0. The registry accepts it without comment, and the next
pull gets the new image.
You need evidence tied to the artifact itself, not to its name. In this guide, you identify the image by its digest, sign that digest with a Cosign key pair, and verify the signature. Then you push a different image over the tag and watch verification catch it.
The terminals on this page are simulations. Click through them to see what each command prints, then run the same commands against your own repository.
What you’ll do
- Look up the digest that
myorg/my-app:1.0currently points at. - Generate a key pair and sign that digest.
- Verify the signature, and find it stored next to the image in the registry.
- Push a different image over the tag, and watch verification fail.
- Attach a signed VEX attestation to the image.
- See a cluster that requires signatures reject the image pushed over the tag.
Prerequisites
To follow along on your own machine, you need:
- Docker, with a Docker Hub account you can push to. See
Get Docker. Sign in with
docker login. Cosign reads the same registry credentials. - Cosign 3.x. See Cosign installation. Cosign 3 stores signatures differently from 2.x, so the output on this page matches 3.x only.
The examples use a Docker Hub repository named myorg/my-app. Replace
myorg with your Docker Hub namespace. Your digests will differ from the
ones shown here.
Starting state
~/my-app is a small Node.js Express API (package.json,
package-lock.json, and index.js, listening on port 3000) built on a Docker
Hardened Image:
# syntax=docker/dockerfile:1
FROM dhi.io/node:22-alpine3.23-dev AS builder
WORKDIR /usr/src/app
COPY package*.json ./
RUN npm ci --omit=dev
COPY . .
FROM dhi.io/node:22-alpine3.23
ENV PATH=/app/node_modules/.bin:$PATH
COPY --from=builder --chown=node:node /usr/src/app /app
WORKDIR /app
CMD ["node", "index.js"]Pulling the base image requires docker login dhi.io with your Docker ID.
Any image you can push works for this guide. Signing doesn’t depend on what
the image contains.
Build the image and push it:
cd ~/my-app
docker build -t myorg/my-app:1.0 .
docker push myorg/my-app:1.0Find the digest behind the tag
A digest is the SHA-256 hash of an image’s manifest. The manifest lists the image’s configuration and layers by their own hashes, so changing any byte of the image produces a different digest. You can’t push different content under an existing digest. You can only push it under a new one.
Ask the registry what 1.0 points at right now:
Host machine
docker buildx imagetools inspect myorg/my-app:1.0
Name: docker.io/myorg/my-app:1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e
Manifests:
Name: docker.io/myorg/my-app:1.0@sha256:87d9d1abb01b626c697d080adc69ac5591a830c5e6e25d54f84655a553243b21
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Name: docker.io/myorg/my-app:1.0@sha256:24f6a42710b75155a9a88d238da67f19b6fbb79cd88756bfae6ffb34b6e994fb
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.digest: sha256:87d9d1abb01b626c697d080adc69ac5591a830c5e6e25d54f84655a553243b21
vnd.docker.reference.type: attestation-manifestThe top-level Digest belongs to the image index. The index lists the image
for each platform, plus the build attestations that BuildKit attached. When
someone pulls myorg/my-app:1.0, the registry resolves the tag to this index
digest, so this is the digest to sign.
Store it in a variable for the rest of the guide:
Host machine
export IMAGE=docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e
$IMAGE now names one exact artifact. It will still name that artifact
next week, whatever happens to the tag.
Generate a key pair
Cosign can sign with a key pair you manage, or without one (“keyless”),
using a short-lived certificate tied to an identity such as a CI workflow. A
local key pair is the simplest way to see how signing works, so this guide
uses one. In ~/my-app, generate it:
Host machine
cosign generate-key-pair
Enter password for private key again: Private key written to cosign.key Public key written to cosign.pub
The two files have different jobs:
cosign.keyis the private key, encrypted with the password you just entered. Anyone who has the file and the password can sign as you. Never commit it.cosign.pubis the public key. Verifiers use it to check signatures, and it can’t sign anything, so it’s safe to commit and share.
Sign the image by digest
Sign the digest, not the tag:
Host machine
cosign sign --key cosign.key $IMAGE
Enter password for private key: Signing artifact... / Pushing signature to: index.docker.io/myorg/my-app
Two things happened:
- The signature went to the registry. Cosign pushed it to the same repository as a separate artifact that refers to the image’s digest. The image itself isn’t modified, and its digest stays the same.
- The signing event was recorded in a public log. By default, Cosign records the signature in Rekor, Sigstore’s public transparency log, without asking first, even when the repository is private. The entry holds a hash of the signed statement, which names the digest, plus the signature and your public key, and it can’t be removed. Your image’s contents are not uploaded.
In a script, the COSIGN_PASSWORD environment variable supplies the
password.
Why the digest? Signing myorg/my-app:1.0 means Cosign first resolves the
tag, and the tag can move between the time you check the image and the time
you sign it. Cosign warns if you try:
WARNING: Image reference myorg/my-app:1.0 uses a tag, not a digest, to identify the image to sign.
This can lead you to sign a different image than the intended one. Please use a
digest (example.com/ubuntu@sha256:abc123...) rather than tag
(example.com/ubuntu:latest) for the input to cosign. The ability to refer to
images by tag will be removed in a future release.Verify the signature
Anyone with cosign.pub can now check the image:
Host machine
cosign verify --key cosign.pub $IMAGE
Verification for index.docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e --
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
[{"critical":{"identity":{"docker-reference":"index.docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e"},"image":{"docker-manifest-digest":"sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e"},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{}}]Each check says something different:
- The cosign claims were validated. The signed payload names a digest, and that digest matches the image being verified. The signature belongs to this exact artifact, not just to something in the same repository.
- Existence of the claims in the transparency log was verified offline. The signature carries proof that it was entered in Rekor, so it can’t have been created privately and attached later without a trace. Cosign checks that proof without contacting Rekor.
- The signatures were verified against the specified public key. Only
the holder of
cosign.keycould have produced this signature.
cosign verify exits with status 0 on success and non-zero on failure, so
a script or pipeline can act on it.
Find the signature in the registry
The signature is stored next to the image, not inside it. cosign tree
lists everything that refers to a digest:
Host machine
cosign tree $IMAGE
📦 Supply Chain Security Related artifacts for an image: docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e └── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: index.docker.io/myorg/my-app@sha256:4fee6228d8596fd4cdad02b28bd4224597697aaca7e4c7e118f94df61626773e └── 🍒 sha256:033ab6a5764f02df28cdb10fd1723d1232afdace5694fa87c1fd6898eaebe3f2
Cosign 3 stores the signature as an OCI referrer: a small artifact whose manifest names the image digest as its subject. The layer under it is a Sigstore bundle, which holds the signature and the transparency log proof. Because the signature points at the digest, it can’t follow a tag anywhere.
Push over the tag, and watch verification fail
Now, simulate the problem from the start of this guide. Someone changes
index.js, rebuilds, and pushes the result as myorg/my-app:1.0 again. Check
the tag:
Host machine
docker buildx imagetools inspect myorg/my-app:1.0
Name: docker.io/myorg/my-app:1.0
MediaType: application/vnd.oci.image.index.v1+json
Digest: sha256:fdb893afb50dddf28f92768c471ab3cc7e4a79c50de11e4cfd16da6638429543
Manifests:
Name: docker.io/myorg/my-app:1.0@sha256:19ddf5984341355252b47604fc6bf13b6fb0ccc89fb7567a5b1e59b3c49533a3
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: linux/arm64
Name: docker.io/myorg/my-app:1.0@sha256:dd683fcbd136731cc353d113f0b0b4c5428d001daa8d9cf667a9662c634eb44b
MediaType: application/vnd.oci.image.manifest.v1+json
Platform: unknown/unknown
Annotations:
vnd.docker.reference.digest: sha256:19ddf5984341355252b47604fc6bf13b6fb0ccc89fb7567a5b1e59b3c49533a3
vnd.docker.reference.type: attestation-manifestcosign verify --key cosign.pub docker.io/myorg/my-app:1.0
Error: no signatures found error during command execution: no signatures found
The tag now resolves to sha256:fdb893…, and nothing signed that digest.
Your signature is still in the registry, but it names sha256:9de0a6…, so it
can’t vouch for the new image. A tampered image fails the same way: changing
any layer changes the digest, and the signature no longer matches.
The original image hasn’t gone anywhere. Verify it by digest:
Host machine
cosign verify --key cosign.pub $IMAGE
Verification for index.docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e --
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
[{"critical":{"identity":{"docker-reference":"index.docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e"},"image":{"docker-manifest-digest":"sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e"},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{}}]This is the practical rule: verify by digest, and deploy the digest you
verified. cosign verify accepts a tag, but it resolves the tag when it
runs. If you verify myorg/my-app:1.0 and then deploy myorg/my-app:1.0,
the tag can move in between.
Attach a signed attestation
A signature says “this key approved this digest.” An attestation is a signed statement about a digest: an SBOM, build provenance, or a VEX document. Cosign signs these with the same key, and stores them as referrers in the same way.
Suppose Docker Scout reports CVE-2024-29415 in the image’s ip package,
which has no fixed version, and your team has established that the
vulnerable function never runs.
An OpenVEX document records that claim:
{
"@context": "https://openvex.dev/ns/v0.2.0",
"@id": "https://openvex.dev/docs/public/vex-749f79b50f5f2f0f07747c2de9f1239b37c2bda663579f87a35e5f0fdfc13de5",
"author": "security@myorg.example",
"timestamp": "2026-10-15T09:20:22.395824Z",
"version": 1,
"statements": [
{
"vulnerability": { "name": "CVE-2024-29415" },
"products": [
{
"@id": "pkg:docker/myorg/my-app@1.0",
"subcomponents": [{ "@id": "pkg:npm/ip@2.0.1" }]
}
],
"status": "not_affected",
"justification": "vulnerable_code_not_in_execute_path"
}
]
}Writing a VEX document, and deciding when a CVE really doesn’t affect you, is covered in Document vulnerabilities that don’t affect your application. Here, the goal is to sign the document and bind it to the digest, so anyone can check who made the claim and which image it’s about.
Attach it, then verify it:
Host machine
cosign attest --key cosign.key --type openvex --predicate CVE-2024-29415.vex.json $IMAGE
Using payload from: CVE-2024-29415.vex.json Enter password for private key:
cosign verify-attestation --key cosign.pub --type openvex $IMAGE > /dev/null
Verification for docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e -- 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
cosign tree $IMAGE
📦 Supply Chain Security Related artifacts for an image: docker.io/myorg/my-app@sha256:9de0a63da0923949ad0399dfc4a38cd952d83c95bb2c994af7d6ad424171723e └── 🔗 https://sigstore.dev/cosign/sign/v1 artifacts via OCI referrer: index.docker.io/myorg/my-app@sha256:4fee6228d8596fd4cdad02b28bd4224597697aaca7e4c7e118f94df61626773e └── 🍒 sha256:033ab6a5764f02df28cdb10fd1723d1232afdace5694fa87c1fd6898eaebe3f2 └── 🔗 https://openvex.dev/ns artifacts via OCI referrer: index.docker.io/myorg/my-app@sha256:dbccdd5b9238fcda0612030fe2df30c6257d55dfce3cc3b7982a2f4054eb0fed └── 🍒 sha256:95049b5e7748979bda5a50c484775bab99143fd665d3ff1b6eb76a9ae9e04c0f
cosign verify-attestation also prints the signed envelope on standard
output. Redirecting it to /dev/null leaves only the checks. Without the
redirect, decode the envelope’s payload field to read the VEX statement
that was verified.
--type openvex sets the attestation’s predicate type to
https://openvex.dev/ns, which is what a verifier, or an admission policy,
asks for. The image digest still hasn’t changed. Both artifacts are
referrers.
See a cluster reject the image
Verification on your laptop is evidence. A cluster that runs nothing without
that evidence is enforcement. The following cluster already has
Kyverno installed, with an ImageValidatingPolicy
named require-signed-my-app. The policy admits a myorg/my-app image into
the production namespace only if a signature on its digest verifies against
the identity the policy trusts. Here, that identity is your release
workflow, not cosign.pub.
Deploy the image that was pushed over 1.0, by its digest:
Cluster admin
kubectl create deployment my-app -n production --image=docker.io/myorg/my-app@sha256:fdb893afb50dddf28f92768c471ab3cc7e4a79c50de11e4cfd16da6638429543
error: failed to create deployment: admission webhook "ivpol.validate.kyverno.svc-fail-finegrained-require-signed-my-app" denied the request: Policy require-signed-my-app failed: image must be signed by the myorg/my-app release workflow on main
The cluster ran the same check you ran by hand. It looked for a signature on that digest, found none from the identity it trusts, and refused the Deployment before anything was stored or pulled. The unsigned image never started.
This policy also rejects the image you signed in this guide. A key on your laptop isn’t the identity the cluster trusts. Production signatures come from the pipeline. Writing this policy is covered in Only run trusted images in production.
Check yourself
- Someone pushes a rebuilt image over
myorg/my-app:1.0. It depends on the reference. Verifying the tag fails, because it now resolves to an unsigned digest. Verifying the original digest passes. - An attacker without
cosign.keysigns a different image with their own key. Verification fails. Their signature doesn’t verify against your public key. - The signed image has a critical CVE. Verification passes. A signature isn’t a statement about quality.
cosign.keyand its password leak. Verification passes for the attacker’s image too. Protect the key, or don’t have one: sign keyless.- Was the image built from a reviewed commit? A signature can’t answer this. Build provenance can.
What a signature proves, and what it doesn’t
A valid signature from cosign.pub establishes two things: the artifact with
this digest hasn’t changed since it was signed, and someone holding
cosign.key approved it. That’s a narrower claim than it looks:
- It proves who signed, not that the code is good. A signed image can still contain vulnerabilities, or a bug that someone approved by mistake.
- “Who” means “whoever has the key.” If the key leaks, so does your identity. Rotating a key means distributing a new public key to every verifier, and deciding what to do about images signed with the old one.
- It says nothing about how the image was built. For that, you need provenance: an attestation, signed by the build system, that records the source and the build steps.
Keyless signing from CI addresses the first two gaps in a pipeline: there’s no long-lived key to steal, and the signature names the workflow that produced the image, not just a key.
Next steps
Signing by hand proves the mechanics. In production, the pipeline that builds
the image should sign it, with an identity that verifiers can check. Continue
with Build a trusted container delivery pipeline,
which builds, scans, attests, and signs myorg/my-app keyless from GitHub
Actions.
Part of a learning path
This works on its own and is also included in the paths below. Open a path to see the complete route.
Learning path
Secure your software supply chain
Step 5 of 7
- Step 1: Build your application on a trusted foundation
- Step 2: Find and fix vulnerabilities before you ship
- Step 3: Document vulnerabilities that don't affect your application
- Step 4: Enforce your organization's container standards
- Step 5: Prove where your container image came from (this page)
- Step 6: Build a trusted container delivery pipeline
- Step 7: Only run trusted images in production
Table of contents