cosign: sign & verify by identity
Verify who signed, not just that it is signed.
In 2021 someone tampered with Codecov's bash uploader script, and no cryptography failed at all. Downstream builds fetched an artifact by its name and trusted it, because the name was the only thing they ever checked. Now run that same story again, this time with signing bolted on. An attacker gets push access to a mirror of your registry, builds a backdoored copy of your api image, and signs it with Sigstore (the free public service for signing software) using their own GitHub Actions identity. Nothing was stolen. The signature is real, cryptographically valid, and written into a public log for anyone to read. A verifier that asks only "is there a valid signature here?" answers yes and admits the backdoor. cosign exists to answer a harder question: was this signed by the identity that is allowed to sign it?
cosign is Sigstore's command line tool for signing and verifying OCI artifacts (Open Container Initiative, the packaging standard your container images and registries already speak). It signs images, and through attestations it signs the SBOMs and provenance documents that describe them. It always signs an artifact by its digest (sha256:...), the fingerprint of those exact bytes, and never by a tag like :latest, which anyone can repoint tomorrow afternoon. The signature goes back into the same registry as a small companion object tagged sha256-<digest>.sig, so it rides along with the image: anyone who can pull the artifact can pull and check its signature. One distinction runs underneath everything below. Producing a signature is cheap, and anybody on earth can produce one. Verifying that a signature came from a specific, expected identity is the control.
What actually happens when you sign with no key
Signing with nothing to sign with sounds like a contradiction, so start with a building pass. A contractor shows ID at the front desk, gets a visitor badge printed with their name, uses it for the morning, and drops it in the bin on the way out. Nothing permanent exists to steal. Keyless signing works the same way. In CI (continuous integration, the build robot that runs on every push) you run cosign sign and hand it no key. cosign generates a throwaway key pair in memory, then proves who it is to Fulcio, Sigstore's certificate authority, using an OIDC token (OpenID Connect, the standard behind every "sign in with Google" button). On GitHub Actions that token is the workflow's own id-token, and its subject is the workflow file path plus the git ref being built, for example .../release.yml@refs/heads/main. Fulcio returns a short-lived X.509 certificate (the same certificate format your browser checks on every HTTPS site) that is good for roughly ten minutes and says: this throwaway public key belongs to that verified identity. cosign signs the digest, uploads the signature and the certificate to the registry, records the event in Rekor (Sigstore's transparency log, a public ledger that only ever grows and never rewrites), then throws the private key away. There is no long-lived signing key to store, rotate, or leak. Trust rests on a verified identity rather than on how well you guard a secret.
Two shapes of signed payload matter here. A plain image signature uses Sigstore's simple signing format, a small JSON blob naming the digest that was signed and little else. When you sign evidence instead of the image itself, using cosign attest for an SBOM (software bill of materials, the ingredient list for a build) or for SLSA provenance (SLSA is Supply-chain Levels for Software Artifacts, and provenance is the record of who built what, from which source), cosign wraps the in-toto statement (in-toto is the standard format for signed claims about a build) inside a DSSE envelope. DSSE stands for Dead Simple Signing Envelope, and it is a JSON object with three parts: a payloadType of application/vnd.in-toto+json, the base64 payload, and a signatures array. The envelope earns its keep through what it signs over. DSSE uses PAE (pre-authentication encoding), which feeds the payload and its type label into the signature together, gluing the label to the contents. An attacker cannot peel the label off a signed SBOM and restick it as signed provenance. Same identity machinery, richer cargo. That is what later lets an admission gate demand a signed image AND signed provenance AND a signed SBOM, all bound to the same digest.
# GitHub Actions job needs: permissions: { id-token: write, packages: write }# cosign v2 defaults to keyless when no --key is given; --yes skips the confirm prompt.$ cosign sign --yes \registry.acme.internal/api@sha256:9f2c3d7b1e0a4f8c6b2d9e1a3c5f7b90d2e4a6c8b0d1f3a5c7e9b1d3f5a7c9e1bGenerating ephemeral keys...Retrieving signed certificate...(the sigstore public-good service ToS notice prints here)Successfully verified SCT...tlog entry created with index: 148263592Pushing signature to: registry.acme.internal/api
Verification is the control, and you have to pin the identity
Signing proves authenticity only if verification insists on the right signer. cosign v2 made that non-negotiable by requiring two flags for keyless verification. --certificate-identity is the subject on the certificate, meaning your workflow. --certificate-oidc-issuer is the identity provider that vouched for it. Leave either one out and cosign v2 refuses to run at all. That refusal is deliberate, and it exists because cosign v1's unbound verify would accept any certificate Fulcio had ever issued, which in practice meant anyone with a GitHub account. Pinning both flags is the whole game. It converts "a valid Sigstore signature exists" into "my release pipeline, authenticated by GitHub's identity provider, produced this exact signature."
$ cosign verify \--certificate-identity="https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main" \--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \registry.acme.internal/api@sha256:9f2c3d7b1e0a...Verification for registry.acme.internal/api@sha256:9f2c3d7b1e0a... --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 code-signing certificate was verified using trusted certificate authority certificates[{"critical": {"identity": { "docker-reference": "registry.acme.internal/api" },"image": { "docker-manifest-digest": "sha256:9f2c3d7b1e0a..." },"type": "cosign container image signature"},"optional": {"Issuer": "https://token.actions.githubusercontent.com","Subject": "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main","Bundle": {"SignedEntryTimestamp": "MEUCIQD...==","Payload": {"logIndex": 148263592,"logID": "c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d","integratedTime": 1752681600}},"githubWorkflowRef": "refs/heads/main","githubWorkflowSha": "2e4a6c8b0d1f3a5c7e9b1d3f5a7c9e1bd2e4a6c8"}}]
A successful verify prints three checks, then the signed payload. "The code-signing certificate was verified using trusted certificate authority certificates" means cosign followed the Fulcio certificate up to Sigstore's root, which it fetched from the Sigstore TUF trust root. TUF is The Update Framework, a signed, self-updating bundle of trust anchors (the Fulcio CA certificate, Rekor's public key, the certificate transparency log key), so you never paste a key into config by hand. "Existence of the claims in the transparency log was verified offline" means cosign checked Rekor's SignedEntryTimestamp, which is Rekor's own signature over your log entry, reading it out of the bundle cosign saved at signing time. No network call happens. And the optional block echoes back the Issuer and Subject it matched, plus the Rekor logIndex. That index is your durable receipt, witnessed by a third party, that this signing really happened.
# The attacker's backdoored image IS validly signed — but by THEIR identity:$ cosign verify \--certificate-identity="https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main" \--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \registry.acme.internal/api@sha256:BADBADBAD...Error: no matching signatures: none of the expected identities matched what was in the certificate,got subjects [https://github.com/mallory/evil/.github/workflows/build.yml@refs/heads/main]with issuer https://token.actions.githubusercontent.commain.go:74: error during command execution: no matching signatures# exit status 1 — the signature is genuine, but it is not YOUR signature. The gate rejects it.
Reading the proof: certificate fields, the SCT, and the log entry
The identity you pin is not free text you invent. It is printed onto the certificate at issue time, the way a name is printed on a badge rather than scribbled on afterwards in biro. Fulcio writes your workflow identity into the certificate's SubjectAlternativeName field (usually shortened to SAN) as a URI. It stamps the OIDC issuer into a custom X.509v3 extension under the OID arc 1.3.6.1.4.1.57264.1.*, where OID means object identifier, the numbered namespace X.509 uses for custom fields, and the issuer sits at ...1.8. Alongside it Fulcio records the repository, the ref, the commit SHA, and the runner environment. --certificate-identity is matched against the SAN. --certificate-oidc-issuer is matched against that extension. The certificate also carries an SCT, a Signed Certificate Timestamp, which is Fulcio's counter-signed promise to a Certificate Transparency log that this identity binding was published where anyone can see it. cosign checks the SCT, and that is the "Successfully verified SCT" line you saw at signing time. It exists so a rogue or backdoored certificate authority cannot mint identities in private.
The transparency guarantee has two layers, and keeping them apart is worth the effort. The SignedEntryTimestamp is Rekor's signed promise that it accepted your entry at a given moment, like a courier handing you a stamped receipt at the door. The inclusion proof is stronger. It is the Merkle audit path (a Merkle tree hashes records in pairs, then hashes those hashes, until one number at the top stands in for the whole log), and it is the short list of sibling hashes that lets you recompute Rekor's signed tree head starting from your own entry. That proves your record is genuinely sitting in the append-only log and was never quietly dropped or reordered. It is the parcel showing up on the manifest, not the receipt. rekor-cli get --log-index decodes the entry and the Fulcio certificate embedded in it, but it does not hand you the proof. The inclusion proof lives in Rekor's REST response under verification.inclusionProof, a separate lookup against the same logIndex. The deep Merkle mechanics belong to the Rekor lesson. What matters here is that the logIndex in your verify output is a public receipt that anyone can confirm without asking you.
# The verify output pointed at logIndex 148263592. The inclusion proof is NOT in# `rekor-cli get` output (its JSON is Body/LogIndex/IntegratedTime/UUID/LogID) —# pull the proof from Rekor's REST API, whose entry carries verification.inclusionProof:$ curl -sS "https://rekor.sigstore.dev/api/v1/log/entries?logIndex=148263592" \| jq '.[] | {logIndex, integratedTime, proof: (.verification.inclusionProof | {treeSize, rootHash})}'{"logIndex": 148263592,"integratedTime": 1752681600,"proof": { "treeSize": 148263810, "rootHash": "3f7a2b9c...c19b" }}# The Fulcio cert IS in the entry — read the pinned identity straight out of it:$ rekor-cli get --log-index 148263592 --format json \| jq -r '.Body.HashedRekordObj.signature.publicKey.content' \| base64 -d | openssl x509 -noout -ext subjectAltNameX509v3 Subject Alternative Name: criticalURI:https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main# The SAN is exactly the string you pin with --certificate-identity. No match, no trust.
@refs/heads/main becomes @refs/tags/v1.4.0, and your exact-match verify starts failing. The tempting fix is --certificate-identity-regexp='.*', which tells cosign to accept every certificate Fulcio has ever issued. An attacker's own keyless signature then sails straight through. If you need the flexibility, anchor the pattern to your repository and your workflow file, and keep the issuer exact: --certificate-identity-regexp='^https://github\.com/acme/api/\.github/workflows/release\.yml@refs/tags/v.*$' with --certificate-oidc-issuer='https://token.actions.githubusercontent.com'. Never leave either side open.Two things bite in production. Verification costs time, so an admission controller that verifies on every image pull should lean on the signing bundle and keep the Rekor and SCT checks offline. Calling the public-good Rekor instance on every deploy buys you latency plus an outside service your cluster now needs before it can start a pod. The second one is quieter. Identity pinning means something only when the identity is one you control. A signature from your protected release workflow carries different weight from one produced by a pull-request workflow that any outside contributor can trigger by opening a PR against your repository. Pin the exact workflow file and ref, not the repository. Fulcio is next, and the question it answers is why a ten-minute certificate with no stored private key can be worth more than a KMS key (key management service, the cloud vault that holds private keys for you) you have guarded carefully for three years.
cosign verify and gets exit 0 on a backdoored image that an attacker signed keyless with their own GitHub Actions identity. The command passed --certificate-identity-regexp='.*' along with --certificate-oidc-issuer='https://token.actions.githubusercontent.com'. Why did a real attack sail through?Try this
Work through “Reading the proof: certificate fields, the SCT, and the log entry” yourself on a sandbox you can throw away, following the commands above in order. Then break one step deliberately and re-run, so you have seen the failure before it finds you.
Takeaway
The trap worth remembering here: a wildcard identity is signing theater, so anchor the regexp instead. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.