Guide

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.0 is 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 with cosign tree
  • Watch verification fail when someone pushes a different image over a signed tag
  • Attach a signed attestation to an image with cosign attest

The terminals below are simulated. Nothing runs on your machine, and each one starts from the state the guide has reached at that point.

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

  1. Look up the digest that myorg/my-app:1.0 currently points at.
  2. Generate a key pair and sign that digest.
  3. Verify the signature, and find it stored next to the image in the registry.
  4. Push a different image over the tag, and watch verification fail.
  5. Attach a signed VEX attestation to the image.
  6. 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.0

Find 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-manifest

The 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.key is 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.pub is 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.key could 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-manifest
cosign 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":{}}]
A signature follows the digest, not the tag Inside the docker.io/myorg/my-app repository, the tag 1.0 used to point at image index sha256:9de0a6, and now points at image index sha256:fdb893, which someone pushed over the tag. The signature and the VEX attestation, both signed with cosign.key, are referrers whose subject is sha256:9de0a6. Nothing refers to sha256:fdb893, so verifying the tag fails, and verifying the original digest still passes. REGISTRY · DOCKER.IO/MYORG/MY-APP tag 1.0 before now Image index sha256:9de0a6… the image you built and signed Image index sha256:fdb893… pushed over the tag later Signature signed with cosign.key VEX attestation signed with cosign.key subject No referrers. Nothing vouches for it.

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

  1. 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.
  2. An attacker without cosign.key signs a different image with their own key. Verification fails. Their signature doesn’t verify against your public key.
  3. The signed image has a critical CVE. Verification passes. A signature isn’t a statement about quality.
  4. cosign.key and its password leak. Verification passes for the attacker’s image too. Protect the key, or don’t have one: sign keyless.
  5. 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.

Learning paths

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.