| Tested on | Rocky Linux 10.2 (Red Quartz) workstation |
|---|---|
| Package | cosign 3.1.2podman 5.8.2skopeojq |
| 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.
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:
sudo dnf install -y skopeo jqPin 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:
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/cosignA 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:
cosign versionGitVersion: v3.1.2
GitCommit: 193d2153431f8bb0d945a4c1ee721872f73add67
BuildDate: 2026-07-17T14:32:20Z
GoVersion: go1.26.4
Platform: linux/amd64Record 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:
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"Upstream reference: gcr.io/distroless/static-debian12@sha256:a9fcaedd4c9b59e12dd65d954f0b5044f19b0647a8a3712e77205df9e7b102cdPodman 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:
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
doneCopy the image and resolve the new local manifest digest:
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"Lab reference: localhost:5000/distroless-lab/static-debian12@sha256:fea0d12d7bf2ff79b9b1902937250bfd0dc9a688214ab6fad5e7f949471deb19Use $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.
mkdir -p ~/cosign-lab && cd ~/cosign-lab
export COSIGN_PASSWORD=''
printf '\n' | cosign generate-key-pairPrivate key written to cosign.key
Public key written to cosign.pubTreat the private key carefully:
- Store
cosign.keywith restrictive permissions (chmod 600) and never commit it to git. - Distribute
cosign.pubto 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:
export COSIGN_PASSWORD=''
cosign sign --key cosign.key --allow-http-registry=true -y "$LAB_REF"Signing artifact...
Pushing signature to: localhost:5000/distroless-lab/static-debian12This 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:
cosign verify --key cosign.pub --allow-http-registry=true "$LAB_REF"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:
printf '\n' | COSIGN_PASSWORD='' cosign generate-key-pair --output-key-prefix wrongif 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"
fiExpected failure: signature does not match wrong.pubcosign 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:
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"Error: no signatures found
error during command execution: no signatures foundThis 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:
- Authenticate to an OIDC provider (GitHub Actions, Google Cloud, GitLab, or Sigstore device flow).
- Obtain a short-lived signing certificate bound to your identity.
- Sign the artifact digest with that certificate.
- Publish the signature and verification material to the registry and transparency log.
- Record the certificate identity and issuer in your verify policy.
On a writable local registry, keyless signing looks like this:
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:
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:
cosign verify \
--certificate-identity='[email protected]' \
--certificate-oidc-issuer='https://accounts.google.com' \
"$UPSTREAM_REF"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 issuerExit 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:
cosign verify \
--certificate-identity-regexp='.*' \
--certificate-oidc-issuer='https://accounts.google.com' \
"$UPSTREAM_REF"Wrong identity should fail verification:
cosign verify \
--certificate-identity='[email protected]' \
--certificate-oidc-issuer='https://accounts.google.com' \
"$UPSTREAM_REF"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:
{
"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:
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:
cosign attest --key cosign.key \
--predicate sbom-spdxjson.json \
--type spdxjson \
--allow-http-registry=true -y \
"$LAB_REF"Using payload from: sbom-spdxjson.json
Signing artifact...Verify the attestation with a matching predicate type:
cosign verify-attestation --key cosign.pub \
--type spdxjson \
--allow-http-registry=true \
"$LAB_REF"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 keyDecode and validate the attestation payload:
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
}'{
"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
- Allow Trusted Images with ImagePolicyWebhook
- Scan Kubernetes YAML with Kubesec and KubeLinter
- Secure Container Image Promotion in CI/CD
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.

