Skip to main content
DevSecOps (AppSec in the pipeline) · lesson 7 of 7

Also part of: Docker

Signing Container Images with Cosign: Verify the Digest, Not the Tag

Sign an image in a local registry with a Cosign key pair, verify it, then push different content under the same tag and watch verification refuse it.

By SkillByExample · Published

devsecops supply-chain cosign sigstore containers signing

Every check so far has looked at code before it’s built. Signing protects what comes after: it lets a deploy step prove that the image it’s about to run is the exact one your pipeline produced. This lesson signs an image in a local registry with a Cosign key pair, verifies it, and then swaps the content behind the tag to show why you verify, and deploy, by digest. Signing and verifying use no Sigstore services, though cosign sign still tries to fetch Sigstore’s TUF trust root and only warns if it can’t. Tested with Cosign 3.1.3 (ghcr.io/sigstore/cosign/cosign:v3.1.3), the CNCF Distribution registry 3.1.2 (registry:3.1.2) and Docker Engine 29.8.2.

A registry and an image

A local registry, reachable from the host on 127.0.0.1:5000 and from other containers as registry:5000 on a private network.

$ docker network create signnet
$ docker run -d --name registry --network signnet -p 127.0.0.1:5000:5000 registry:3.1.2

The image is a one-line Python program.

# Dockerfile
FROM python:3.12-slim
CMD ["python", "-c", "print(\"shop 1.0\")"]

Build it, push it, and record its digest: the hash of the manifest the registry now stores.

$ docker build -q -t localhost:5000/shop:1.0 . && docker push -q localhost:5000/shop:1.0
sha256:66924c158acc122753e960075b56f3923f496f9579b84bd5857161f0f232591b
localhost:5000/shop:1.0
$ DIGEST=$(docker inspect --format "{{index .RepoDigests 0}}" localhost:5000/shop:1.0 | cut -d@ -f2)
$ echo $DIGEST
sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d

Two different hashes. The first is the local image ID; the second is the registry digest, and it’s the one that matters, because it’s what other machines pull and what Cosign signs. Your digest will differ, because the image records when it was built.

Generate a key pair

Cosign runs from its own image, on the same network as the registry. The private key is encrypted with the password in COSIGN_PASSWORD; -e COSIGN_PASSWORD passes it from your shell.

$ export COSIGN_PASSWORD=lab-only
$ alias cosign='docker run --rm --network signnet -v "$PWD":/w -w /w -e COSIGN_PASSWORD ghcr.io/sigstore/cosign/cosign:v3.1.3'
$ cosign generate-key-pair
Private key written to cosign.key
Public key written to cosign.pub

cosign.key belongs in a secret store, never in git; the secrets lesson would rightly flag it. cosign.pub is published, so anyone who deploys the image can verify it.

Sign the digest

Cosign 3 signs with a signing config that lists the Sigstore services to use: a transparency log and a timestamp authority. To use no log or timestamp service, create an empty one. (On a machine with no internet access, signing still succeeds after a warning that it couldn’t fetch the TUF root.)

$ cosign signing-config create --out offline.json
$ cat offline.json
{"mediaType":"application/vnd.dev.sigstore.signingconfig.v0.2+json","rekorTlogConfig":{},"tsaConfig":{}}

Now sign. --allow-http-registry is needed because the lab registry has no TLS, and --yes skips the confirmation prompt.

$ cosign sign --key cosign.key --signing-config offline.json --allow-http-registry --yes registry:5000/shop@$DIGEST
Signing artifact...
Pushing signature to: registry:5000/shop

The image is referenced by digest, not tag, so the signature is attached to exactly that content. “Pushing signature to: registry:5000/shop” shows where it went: into the same repository, as an extra artifact linked to the digest. There’s no separate signature server.

Verify it

$ cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry -o text registry:5000/shop@$DIGEST
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.

Verification for registry:5000/shop@sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d --
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":"registry:5000/shop@sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d"},"image":{"docker-manifest-digest":"sha256:c6117dd32be8698811122d2a3104d42dd4b2a2f95986a5c8f7412f496faf754d"},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{}}
$ echo $?
0

Verified, exit 0. The JSON at the end is the signed payload, and its docker-manifest-digest is the digest you signed. Two lines need care. The warning is expected: this lab has no transparency log, so --insecure-ignore-tlog is required. The middle check line still says the transparency log was “verified offline” even though it was skipped; with --insecure-ignore-tlog, read that line as skipped, not passed.

Move the tag to different content

Anyone with push access can repoint a tag. Build a different image and push it as shop:1.0.

# Dockerfile.other
FROM python:3.12-slim
CMD ["python", "-c", "print(\"something else\")"]
$ docker build -q -f Dockerfile.other -t localhost:5000/shop:1.0 . && docker push -q localhost:5000/shop:1.0
sha256:6aff6e18fd70b992779a9cbdc95a73daf9d03958f9b10452949b2c92c16aceef
localhost:5000/shop:1.0
$ cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry -o text registry:5000/shop:1.0
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the signature.
Error: no signatures found
error during command execution: no signatures found
$ echo $?
10

The push succeeded with no complaint: the registry doesn’t know or care about signatures. But verification by tag now fails with exit 10, because Cosign resolves shop:1.0 to the new digest, and nothing was ever signed for that digest. A deploy step that verifies before it runs would stop here.

The original image hasn’t gone anywhere. Verify by its digest and it still passes.

$ cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry -o text registry:5000/shop@$DIGEST >/dev/null 2>&1
$ echo $?
0

That’s the rule this lesson exists for: sign the digest, deploy the digest. A tag tells you what someone meant to deploy; a digest tells you what you will actually run.

When you’re done, clean up with docker rm -f registry && docker network rm signnet.

Practice

  1. Generate a second key pair in a folder called other, and verify the signed digest with other/cosign.pub. What happens?

    Solution

    Run cosign generate-key-pair from inside other (or mount it), then verify with --key other/cosign.pub. Verification fails with exit 1 and accepted signatures do not match threshold, Found: 0, Expected 1. The signature exists but was made by a different key, so it doesn’t count. That’s what stops an attacker who can push to the registry, but doesn’t have your private key, from signing their own image.

  2. Re-sign so that the new shop:1.0 passes verification by tag. Should a pipeline be allowed to do that?

    Solution

    Get the new digest with the same docker inspect command, then run cosign sign on registry:5000/shop@<new digest>. Verification by tag then passes. That’s why the private key is the thing to protect: only the build pipeline that produced an image should hold it, never a person or job that can also push arbitrary content.

  3. Write a one-line deploy guard that runs the image only if verification passes.

    Solution
    cosign verify --key cosign.pub --insecure-ignore-tlog --allow-http-registry registry:5000/shop@$DIGEST >/dev/null 2>&1 && docker run --rm localhost:5000/shop@$DIGEST

    && runs the image only when verify exits 0. Note that both commands use the same digest, so there’s no window in which the tag could move between checking and running. In Kubernetes, an admission controller such as the Sigstore policy-controller does the same check before a pod starts.

Where this fits

This completes the pipeline: threat model, code, secrets, dependencies, the running app, the gate, and finally the artifact itself. The borrowed lessons below add container hardening, CI secrets handling and infrastructure secrets.

Frequently Asked Questions

What does signing a container image prove?
It proves that whoever holds the private key approved that exact image content, identified by its manifest digest. Anyone with the public key can check the signature before running the image. It doesn't prove the image is free of vulnerabilities; it proves the image is the one your pipeline produced and hasn't been swapped.
Why should I verify an image by digest rather than by tag?
A tag such as 1.0 is a movable pointer: anyone with push access can point it at different content. A digest is a hash of the image manifest, so it always names the same bytes. Cosign signs the digest, so verifying a tag only succeeds while the tag still points at the signed content; deploying by digest removes the question entirely.
Where does Cosign store image signatures?
In the same registry as the image, as an extra OCI artifact linked to the image's digest. There's no separate signature server to run. Copying the image to another registry without its signature leaves you with an unsigned image there.
What is the difference between key-based and keyless signing in Cosign?
Key-based signing uses a private key you generate and protect, and verifiers need the matching public key. Keyless signing uses a short-lived certificate tied to an OIDC identity, such as a CI workload or an email address, and records the signature in the public Rekor transparency log. Keyless needs network access to Sigstore's services; key-based signing works offline, which is why this lesson uses it.
Why does cosign verify warn about skipping tlog verification?
By default Cosign expects each signature to be recorded in a transparency log such as Rekor, which makes signing events publicly auditable. Signing with an empty signing config contacts no Sigstore log or timestamp service, so there is no log entry, and verification needs --insecure-ignore-tlog. In production with the public Sigstore instance, leave the transparency log on.