Sign and Verify Container Images with Cosign

Sign and verify container images with Cosign using local keys and keyless identities, attach SPDX attestations, and test failed verification.

Published

Updated

Read time 12 min read

Reviewed byDeepak Prasad

Cosign signing a container image digest with a local key pair and verifying keyless Sigstore certificate identity
Tested on Rocky Linux 10.2 (Red Quartz) workstation
Package cosign 3.1.2
podman 5.8.2
skopeo
jq
Applies to Ubuntu, Debian, Kali Linux, Linux Mint, Pop!_OS, Raspberry Pi OS, elementary OS, Zorin OS, Parrot OS, MX Linux, RHEL, Rocky Linux, AlmaLinux, Oracle Linux, CentOS Stream, Fedora
Cert prep CKS
Lab environment Workstation with Podman — local registry at 127.0.0.1:5000 (quay.io/libpod/registry:2.8.2); reuse gcr.io/distroless/static-debian12 from build secure minimal container images
Privilege Normal user for sign and verify; sudo to install packages and the Cosign binary under /usr/local/bin
Scope Cosign release verification during upgrades, digest pinning, local key generation, key-based sign and verify, transparency-log behaviour, registry signature storage, keyless identity verification, attestation with SPDX SBOM predicates, verification failure cases, and inspection of Cosign output. Does not cover Kubernetes release verification, admission webhooks, enterprise KMS, full Sigstore architecture, vulnerability scanning, or complete SLSA implementation.
Related guides Build and push container images
Verify Kubernetes release artifacts

Cosign binds a cryptographic signature to an exact container image digest. This walkthrough downloads and verifies Cosign 3.1.2 using an already trusted Cosign installation, then signs and verifies container images. For a first installation, bootstrap trust with Sigstore's documented TUF artifact-key procedure before running the identity-based binary verification shown here. On Rocky Linux 10.2, the lab signs a distroless image pushed to a local registry with a test key pair, verifies the signature, validates Google keyless signatures on the public distroless image, attaches an SPDX SBOM attestation, and deliberately fails verification with the wrong key and an unsigned image.

IMPORTANT
This guide covers artifact signing and verification with Cosign. It does not configure Kubernetes admission policy or replace Trivy image scanning. Pair signed digests with scan gates and SBOM archives in your promotion pipeline.

Sign digests, not mutable tags

Tags such as latest or 1.0.0 are pointers. A maintainer can retag the same name to a different manifest without changing the tag string.

  • Digest (sha256:…) identifies the exact manifest bytes.
  • Signature binds your key or OIDC identity to that digest.
  • Promotion should verify image@sha256:… and reject clusters that pull a different digest than the one you signed.

Always pass the digest form to cosign sign and cosign verify, not the tag alone.


Install and verify Cosign

Install supporting tools first:

bash
sudo dnf install -y skopeo jq

Pin the release in CI and on workstations. This lab uses Cosign 3.1.2, published on 17 July 2026.

For an upgrade where an older Cosign binary is already trusted, download and verify the new release before replacing it:

bash
COSIGN_VERSION=3.1.2

curl -sfL \
  "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64" \
  -o /tmp/cosign

curl -sfL \
  "https://github.com/sigstore/cosign/releases/download/v${COSIGN_VERSION}/cosign-linux-amd64.sigstore.json" \
  -o /tmp/cosign.sigstore.json

cosign verify-blob /tmp/cosign \
  --bundle /tmp/cosign.sigstore.json \
  --certificate-identity='[email protected]' \
  --certificate-oidc-issuer='https://accounts.google.com'

chmod +x /tmp/cosign
sudo install -m 0755 /tmp/cosign /usr/local/bin/cosign

A first-time bootstrap requires Sigstore's documented TUF artifact-key procedure. Identity-based verification with an existing Cosign binary is the upgrade path shown below — an untrusted new Cosign binary should not verify itself.

Confirm the installed binary:

bash
cosign version
output
GitVersion:    v3.1.2
GitCommit:     193d2153431f8bb0d945a4c1ee721872f73add67
BuildDate:     2026-07-17T14:32:20Z
GoVersion:     go1.26.4
Platform:      linux/amd64

Record the GitVersion string in your release metadata beside the image digest and scanner versions.


Resolve the image digest

Start from an image you already trust at the content level, then resolve the manifest digest you plan to sign. Use variables so the workflow stays reproducible when mutable tags move:

bash
UPSTREAM_IMAGE=gcr.io/distroless/static-debian12

podman pull "${UPSTREAM_IMAGE}:latest"

UPSTREAM_DIGEST=$(
  skopeo inspect "docker://${UPSTREAM_IMAGE}:latest" |
  jq -r '.Digest'
)

UPSTREAM_REF="${UPSTREAM_IMAGE}@${UPSTREAM_DIGEST}"

printf 'Upstream reference: %s\n' "$UPSTREAM_REF"
output
Upstream reference: gcr.io/distroless/static-debian12@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cd

Podman may also list a config digest under RepoDigests. For signing and verification, use the manifest digest from skopeo inspect, not the local image ID alone.

Start and verify the lab registry:

bash
podman run -d \
  --name cosign-registry \
  -p 127.0.0.1:5000:5000 \
  quay.io/libpod/registry:2.8.2

until curl -fsS http://127.0.0.1:5000/v2/ >/dev/null; do
  sleep 1
done

Copy the image and resolve the new local manifest digest:

bash
LAB_IMAGE=localhost:5000/distroless-lab/static-debian12

podman tag \
  "${UPSTREAM_IMAGE}:latest" \
  "${LAB_IMAGE}:latest"

podman push \
  --tls-verify=false \
  "${LAB_IMAGE}:latest"

LAB_DIGEST=$(
  skopeo inspect \
    --tls-verify=false \
    "docker://${LAB_IMAGE}:latest" |
  jq -r '.Digest'
)

LAB_REF="${LAB_IMAGE}@${LAB_DIGEST}"

printf 'Lab reference: %s\n' "$LAB_REF"
output
Lab reference: localhost:5000/distroless-lab/static-debian12@sha256:fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19

Use $LAB_REF throughout all key-based signing, verification, and attestation commands. This prevents readers from accidentally using a digest belonging to an older build.

Record repository, digest, architecture, build ID, source commit, and release version in your promotion record.


Generate a local key pair

Create a test key pair for the lab. Production pipelines should use managed keys, cloud KMS, or keyless identity instead of long-lived files on developer laptops.

bash
mkdir -p ~/cosign-lab && cd ~/cosign-lab
export COSIGN_PASSWORD=''
printf '\n' | cosign generate-key-pair
output
Private key written to cosign.key
Public key written to cosign.pub

Treat the private key carefully:

  • Store cosign.key with restrictive permissions (chmod 600) and never commit it to git.
  • Distribute cosign.pub to verifiers, CI jobs, and cluster policy engines.
  • Use a passphrase in production; the lab uses an empty passphrase for automation.
  • CI should prefer OIDC keyless signing or a secrets manager rather than copying private key files to runners.

Public-key verification proves that the matching private key signed the digest only when cosign.pub itself was obtained through a trusted channel. Store or distribute its checksum through trusted configuration, source control, or policy data. Otherwise, an attacker who replaces both the image and public key could still produce a successful verification.


Sign with a key

Sign the exact digest on the local registry. Cosign pushes a signature artifact to the same repository:

bash
export COSIGN_PASSWORD=''
cosign sign --key cosign.key --allow-http-registry=true -y "$LAB_REF"
output
Signing artifact...
Pushing signature to: localhost:5000/distroless-lab/static-debian12

This lab uses Cosign's default transparency-log upload. The registry is local, but signing still requires Internet access to Sigstore services and records the signing event publicly. For private workflows where repository or digest metadata must not enter the public log, use --tlog-upload=false; verification must then explicitly use --insecure-ignore-tlog, and you lose public transparency-log evidence.

Cosign stores the signature as an OCI artifact linked to the manifest digest. On production registries over HTTPS, omit --allow-http-registry=true.


Verify with the public key

Verification confirms the signature matches your public key and the digest you expect:

bash
cosign verify --key cosign.pub --allow-http-registry=true "$LAB_REF"
output
Verification for localhost:5000/distroless-lab/static-debian12@sha256:fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19 --
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":"localhost:5000/distroless-lab/static-debian12"},"image":{"docker-manifest-digest":"sha256:fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19"},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{}}]

A successful verify exits 0 and prints JSON claims. The simple signing payload stores the repository identity under critical.identity.docker-reference and the signed manifest digest under critical.image.docker-manifest-digest.

Existence of the claims in the transparency log was verified offline means Cosign validated attached inclusion evidence without querying Rekor during that check. It does not mean that the registry, trusted-root material, and complete workflow were air-gapped.

Correct key

The command above passes with cosign.pub from the same key pair that signed the image.

Wrong key

Generate a second key pair and verify with the wrong public key:

bash
printf '\n' | COSIGN_PASSWORD='' cosign generate-key-pair --output-key-prefix wrong
bash
if cosign verify \
  --key wrong.pub \
  --allow-http-registry=true \
  "$LAB_REF" 2>/dev/null; then
  echo "ERROR: verification unexpectedly passed"
  exit 1
else
  echo "Expected failure: signature does not match wrong.pub"
fi
output
Expected failure: signature does not match wrong.pub

cosign verify checks signatures. Exact nested error text can change between Cosign releases, so treat only the non-zero exit status as the stable pipeline signal. Official verification semantics guarantee success with exit status 0 when a matching signature is found.

Unsigned image

Push a small unsigned image to the lab registry, then verify:

bash
UNSIGNED_IMAGE=localhost:5000/distroless-lab/pause

podman pull registry.k8s.io/pause:3.10

podman tag \
  registry.k8s.io/pause:3.10 \
  "${UNSIGNED_IMAGE}:3.10"

podman push \
  --tls-verify=false \
  "${UNSIGNED_IMAGE}:3.10"

UNSIGNED_DIGEST=$(
  skopeo inspect \
    --tls-verify=false \
    "docker://${UNSIGNED_IMAGE}:3.10" |
  jq -r '.Digest'
)

UNSIGNED_REF="${UNSIGNED_IMAGE}@${UNSIGNED_DIGEST}"

cosign verify \
  --key cosign.pub \
  --allow-http-registry=true \
  "$UNSIGNED_REF"
output
Error: no signatures found
error during command execution: no signatures found

This is a valid registry digest with no matching signature, so it cleanly proves the unsigned-image failure path. Verifying a public image that carries keyless signatures with --key fails differently because other signatures exist.


Sign keylessly

Keyless signing exchanges OIDC authentication for a short-lived Fulcio certificate:

  1. Authenticate to an OIDC provider (GitHub Actions, Google Cloud, GitLab, or Sigstore device flow).
  2. Obtain a short-lived signing certificate bound to your identity.
  3. Sign the artifact digest with that certificate.
  4. Publish the signature and verification material to the registry and transparency log.
  5. Record the certificate identity and issuer in your verify policy.

On a writable local registry, keyless signing looks like this:

bash
cosign sign \
  --allow-http-registry=true \
  -y "$LAB_REF"

Keyless signing requires both OIDC authentication and permission to attach a signature to the image repository. The local registry is writable in this lab. Signing Google's upstream distroless repository would fail after authentication because normal users cannot push artifacts there:

bash
cosign sign -y "$UPSTREAM_REF"

Authentication might succeed, but the command cannot attach a signature to gcr.io/distroless/static-debian12 unless you control that repository.

On a headless workstation without an identity token, cosign sign falls back to the Sigstore device flow and waits for browser authentication. In CI, pass a workload identity token instead of using the device flow. This article verifies existing keyless signatures on the public distroless image rather than producing a new keyless signature against Google's repository.


Verify keyless identity

Keyless verification checks the Fulcio certificate identity and OIDC issuer instead of a static public key file. Google publishes keyless signatures for distroless images.

Verify with an exact identity and issuer:

bash
cosign verify \
  --certificate-identity='[email protected]' \
  --certificate-oidc-issuer='https://accounts.google.com' \
  "$UPSTREAM_REF"
output
Verification for gcr.io/distroless/static-debian12@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cd --
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 claims were verified
  - The certificate extensions were verified
  - The certificate was verified against the Fulcio certificate authority
  - The certificate issuer matched the expected issuer

Exit code 0.

Use tightly anchored identity matching in production. Broad regex patterns are convenient in labs but weaken policy. This deliberately broad example uses an exact issuer instead of an unanchored issuer regex:

bash
cosign verify \
  --certificate-identity-regexp='.*' \
  --certificate-oidc-issuer='https://accounts.google.com' \
  "$UPSTREAM_REF"

Wrong identity should fail verification:

bash
cosign verify \
  --certificate-identity='[email protected]' \
  --certificate-oidc-issuer='https://accounts.google.com' \
  "$UPSTREAM_REF"
output
Error: no matching signatures: invalid signature verified with certificate, expected [email protected], got [email protected]

Pin --certificate-identity and --certificate-oidc-issuer to the signer your pipeline expects, not a wildcard that accepts any publisher.


Inspect verification output

Read the JSON payload Cosign prints after a successful verify:

json
{
  "critical": {
    "identity": {
      "docker-reference": "localhost:5000/distroless-lab/static-debian12"
    },
    "image": {
      "docker-manifest-digest": "sha256:fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19"
    },
    "type": "https://sigstore.dev/cosign/sign/v1"
  },
  "optional": {}
}
Evidence Where it comes from
critical.identity.docker-reference Signed Cosign claim
critical.image.docker-manifest-digest Signed Cosign claim
Certificate identity and issuer Fulcio certificate checked against verification flags
Transparency-log result Verification status and bundle or log evidence
Optional annotations optional section of the signed claim

The certificate subject and issuer are not fields inside the basic critical image claim.

Inspect attached supply-chain artifacts in the registry:

bash
cosign tree \
  --allow-http-registry=true \
  "$LAB_REF"

Cosign v3 stores signatures as OCI 1.1 referring artifacts, and cosign tree is the documented way to inspect attached supply-chain artifacts.

Store the digest, signer identity, issuer, Cosign version, and verification timestamp with every promotion record.


Add attestations

Attestations attach signed predicates such as SBOMs, build provenance, scan results, or policy outcomes to the same digest. Generate an SPDX JSON file with Trivy, then attach it:

bash
cosign attest --key cosign.key \
  --predicate sbom-spdxjson.json \
  --type spdxjson \
  --allow-http-registry=true -y \
  "$LAB_REF"
output
Using payload from: sbom-spdxjson.json
Signing artifact...

Verify the attestation with a matching predicate type:

bash
cosign verify-attestation --key cosign.pub \
  --type spdxjson \
  --allow-http-registry=true \
  "$LAB_REF"
output
Verification for localhost:5000/distroless-lab/static-debian12@sha256:fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19 --
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

Decode and validate the attestation payload:

bash
cosign verify-attestation \
  --key cosign.pub \
  --type spdxjson \
  --allow-http-registry=true \
  "$LAB_REF" |
jq -r '.payload' |
base64 -d |
jq '{
  predicateType,
  subject,
  spdxName: .predicate.name
}'
output
{
  "predicateType": "https://spdx.dev/Document",
  "subject": [
    {
      "name": "localhost:5000/distroless-lab/static-debian12",
      "digest": {
        "sha256": "fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19"
      }
    }
  ],
  "spdxName": "localhost:5000/distroless-lab/static-debian12"
}

predicateType should identify the SPDX document type. subject[].digest.sha256 must match $LAB_DIGEST without the sha256: prefix. .predicate contains the SPDX document that was supplied to cosign attest. Cosign attest and verify-attestation both support spdxjson as a recognized predicate type.

Use separate attestations for vulnerability scan reports and SLSA provenance rather than mixing predicate types in one file.


Test verification failures

Test Command change Expected result
Wrong identity --certificate-identity='[email protected]' on keyless verify Error: identity mismatch
Wrong issuer --certificate-oidc-issuer='https://evil.example' Certificate issuer mismatch
Wrong public key cosign verify --key wrong.pub Non-zero exit; signature does not match
Unsigned digest Verify $UNSIGNED_REF never signed with your key Non-zero exit; no matching signature
Missing registry access Verify without network or credentials Registry pull or auth error
Overly broad regex --certificate-identity-regexp='.*' May pass signatures you did not intend to trust

Treat non-zero exit codes as hard promotion failures. Do not fall back to unsigned pulls when verification fails.


Troubleshooting

Symptom Likely cause Fix
no signatures found Digest mismatch or image never signed Confirm skopeo inspect digest; sign the same @sha256: reference
certificate does not match with --key Image signed keylessly, not with your key pair Use identity or issuer flags for keyless signatures
Device flow blocks CI No OIDC token in non-interactive shell Use GitHub or GitLab OIDC in CI or key-based signing locally
http: server gave HTTP response to HTTPS client Local registry without TLS Add --allow-http-registry=true for lab registries only
toomanyrequests pulling registry image Docker Hub rate limit Use quay.io/libpod/registry or gcr.io mirrors
Attestation invalid attestation Predicate JSON does not match --type Use full SPDX 2.3 document with --type spdxjson
Signed :latest but cluster pulls new digest Tag moved after signature Sign and verify by digest; block tag-only promotion

What's Next


References


Summary

Cosign ties publisher identity to an immutable image digest. Install and verify a pinned release, resolve manifest digests with skopeo, sign with a protected key or OIDC-backed keyless flow on a writable registry, and verify with the public key or certificate identity before promotion.

The lab signed $LAB_REF with a local key, verified Google keyless signatures on $UPSTREAM_REF, attached an SPDX SBOM attestation, and confirmed that wrong keys and unsigned images fail as expected. Next steps include wiring verification into image policy webhooks and your CI promotion gates.


Frequently Asked Questions

1. Should I sign a container image tag or digest?

Sign the digest. Tags are mutable pointers that can move to different layer content. Cosign binds the signature to the manifest digest, so promotion pipelines should verify image@sha256:... and reject digest drift.

2. What is the difference between key-based and keyless Cosign signing?

Key-based signing uses a long-lived key pair you generate and protect. Keyless signing uses a short-lived certificate from Fulcio after you authenticate to an OIDC provider such as GitHub or Google. Verification checks certificate identity and issuer instead of a static public key file.

3. Where does Cosign store signatures?

By default, Cosign stores signatures as OCI artifacts in the same repository as the image. Verifiers retrieve those signature artifacts from the registry and validate them against a public key or expected keyless identity.

4. Does signing an image replace vulnerability scanning?

No. Signatures prove who published an artifact and that the digest was not altered. Scanning with Trivy or similar tools still belongs in a separate gate. You can attach scan results as Cosign attestations, but signing alone does not prove an image is free of CVEs.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive experience, he excels across development, DevOps, networking, and security, delivering robust and efficient solutions for diverse projects.

  • Go (programming language)
  • Python (programming language)
  • DevOps
  • Computer Security
  • Cloud Computing
  • Kubernetes
  • Linux
  • Ansible (software)