CoursesSoftware supply chain in depthcosign: sign & verify by identity

cosign: sign & verify by identity

Verify who signed, not just that it is signed.

Advanced35 min · lesson 7 of 15

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.

keyless sign in CI: identity comes from the workflow's OIDC token
# 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:9f2c3d7b1e0a4f8c6b2d9e1a3c5f7b90d2e4a6c8b0d1f3a5c7e9b1d3f5a7c9e1b
Generating ephemeral keys...
Retrieving signed certificate...
(the sigstore public-good service ToS notice prints here)
Successfully verified SCT...
tlog entry created with index: 148263592
Pushing 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."

verify by identity: both flags are mandatory in cosign v2
$ 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.

wrong signer: the check that actually stops the attack
# 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.com
main.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 tlog entry: inclusion proof from Rekor's API, identity from the Fulcio cert
# 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 subjectAltName
X509v3 Subject Alternative Name: critical
URI: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.
A wildcard identity is signing theater, so anchor the regexp instead
Releases move from a branch to a tag, and the pinned identity moves with them: @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.
What a valid Sigstore signature buys you depends on how you verify
cosign verify returns a cryptographically valid, Fulcio-issued, Rekor-logged signature
the signature is real; the question is whose
identity + issuer pinned to your workflow
ACCEPT
only your release pipeline's signer passes, a real control
identity regexp = .* (or issuer unpinned)
ACCEPT ANY SIGNER
the attacker's own keyless signature passes too: signing theater
no signature, or digest tampered
REJECT
unsigned or altered artifact, exit status 1
A valid signature is necessary but not sufficient. Pinning the exact certificate identity and OIDC issuer is what turns 'signed' into 'signed by us.'

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.

Quick check
01Your admission gate runs 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?
Incorrect — No. verify checks the exact digest you hand it. This passed because the identity constraint was wide open, not because of anything to do with the digest.
Correct — The issuer was pinned but the identity was a wildcard, so the attacker's perfectly valid keyless signature matched. Pin the exact workflow, or an anchored regexp.
Incorrect — No. Log inclusion is checked offline from the bundle stored at signing time, and an unreachable Rekor does not turn into a silent pass.
Incorrect — No. cosign always chains the Fulcio certificate up to Sigstore's trusted root, and this certificate was valid. The unconstrained identity is what failed.
02Keyless verification makes you pin two values, one with --certificate-identity and one with --certificate-oidc-issuer. Which part of the signer's certificate does cosign read each one from?
Incorrect — No. The Subject DN in a Fulcio certificate is empty, and the identity never lives there.
Incorrect — No. Rekor stores a copy of the certificate, but the two pinned strings are read from the certificate's SAN and its OID extension.
Correct — --certificate-identity is matched against the SAN and --certificate-oidc-issuer against that custom extension, both read straight off the certificate.
Incorrect — No. Those two describe where the artifact lives, not who signed it.
03An attacker pushes a backdoored api image to your registry mirror and signs it keyless with their own GitHub Actions identity, producing a genuine Sigstore signature that lands in Rekor like any other. Your gate runs cosign verify with --certificate-identity pinned to https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main and --certificate-oidc-issuer set to the GitHub Actions issuer. What do you get?
Incorrect — No. Being valid and logged is not the control. A real signature from the wrong identity still fails.
Correct — Pinning the identity turns 'a valid signature exists' into 'signed by us', and the attacker's subject does not match.
Incorrect — No. An identity mismatch is a hard failure with exit status 1, not a soft advisory flag.
Incorrect — No. Log inclusion is checked offline from the stored bundle, so nothing blocks on the network.

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.

Related