CoursesSoftware supply chain securitySigstore: Cosign, Fulcio & Rekor

Sigstore: Cosign, Fulcio & Rekor

Keyless signing and the transparency log.

Advanced14 min · lesson 10 of 18

A signing key is like a wax seal stamp. You press it into hot wax, and anyone who recognizes the seal trusts that the letter came from you. The weakness is the stamp itself. Whoever holds it can seal anything in your name, so you lock it in a drawer and hope nobody makes a copy. In continuous integration (CI, the automated system that builds and ships your code), that drawer is a secret file sitting on a build runner, and secrets on build runners get leaked, printed into logs, and copied into forks.

Sigstore changes the deal. Instead of guarding one stamp forever, you visit a notary (an official who checks your identity and witnesses your signature). The notary confirms who you are, hands you a stamp that stops working in about ten minutes, watches you seal the document, and writes the whole event into a public ledger that anyone can read and nobody can quietly edit. Ten minutes later the stamp is dead. There is no drawer to guard, and nothing durable for a thief to copy.

Sigstore is three programs playing those roles. Cosign is the tool you run to sign and verify things (container images, plain files, and attestations, which are signed statements about how an artifact was built). Fulcio is the notary, a certificate authority (a service that issues identity certificates) that hands out signing certificates good for roughly ten minutes, each one bound to a verified identity. Rekor is the ledger, an append-only transparency log (a public record where entries can be added but never changed or removed).

What keyless actually means

Keyless does not mean there is no key. It means there is no key that outlives the moment of signing. When cosign signs, it generates a fresh public and private key pair in memory. Then it has to prove who you are, which it does with an OIDC token (OpenID Connect, the standard login-token format that says 'this request really comes from this identity'). In CI, that token is minted by your platform for the running pipeline. Cosign sends the token and the throwaway public key to Fulcio. Fulcio checks the token, and if it trusts the issuer, it signs a short-lived certificate that says 'this public key belongs to this identity.' Cosign signs your artifact with the matching private key, records everything in Rekor, and throws the private key away.

One keyless signature, start to finish
1Ephemeral keypair
cosign makes a throwaway key in memory
2OIDC token
CI proves the pipeline's identity
3Fulcio certificate
CA binds identity to the key, valid ~10 min
4Sign the digest
private key signs the image bytes
5Rekor entry
logged, timestamped, permanent
6Key discarded
nothing durable left to steal

Look at what an attacker can no longer take. There is no long-lived private key on disk. The certificate that existed is already expired. The only lasting thing is a public record that a specific identity signed a specific set of bytes at a specific time. That record is the whole point of Rekor, and it is what keeps an expired certificate useful months later.

Signing in a pipeline

Here is a GitLab CI job that signs an image keylessly. The part that matters is the id_tokens block. GitLab mints an OIDC token scoped to this pipeline, with the audience Fulcio expects, and exposes it in an environment variable that cosign reads on its own.

.gitlab-ci.yml
sign-image:
image: cgr.dev/chainguard/cosign:latest
id_tokens:
SIGSTORE_ID_TOKEN:
aud: sigstore
variables:
IMAGE: registry.acme.internal/payments-api
script:
# $DIGEST was captured from the build step (sha256:...)
- cosign sign --yes "$IMAGE@$DIGEST"

cosign finds SIGSTORE_ID_TOKEN in the environment, so there is no browser prompt and no interactive login. The --yes flag answers the one confirmation prompt (the reminder that your identity goes into a public log) so the job does not hang waiting for a human. You sign $IMAGE@$DIGEST, the image's content address, never a tag.

terminal
$ cosign sign --yes "$IMAGE@$DIGEST"
output
Generating ephemeral keys...
Retrieving signed certificate...
Note that there may be personally identifiable information associated with this signed artifact.
This may include the email address associated with the account with which you authenticate.
This information will be used for signing this artifact and will be stored in public transparency logs and cannot be removed later.
tlog entry created with index: 84213507
Pushing signature to: registry.acme.internal/payments-api

Every line maps to a step you can hold someone accountable for. 'Generating ephemeral keys' is that throwaway pair being born in memory. 'Retrieving signed certificate' is the round trip to Fulcio, and it quietly does more than it says: the certificate Fulcio hands back carries an SCT (Signed Certificate Timestamp, a receipt proving Fulcio published that certificate to a public certificate transparency log), and cosign rejects the certificate if that receipt does not check out. 'tlog entry created with index: 84213507' is your permanent Rekor record, and that index number is how you fetch the entry back later. The last line stores the signature in the registry right next to the image, as its own small .sig object.

Rekor: the ledger you cannot rewrite

Rekor is built like a tamper-evident notebook where every page carries a fingerprint of all the pages before it (a Merkle tree, a structure where entries are hashed together with their neighbors up to a single root hash). Change one old entry and every fingerprint above it breaks, so the whole log would have to be rewritten at once, in public, which is exactly what everyone watching would notice. You can pull any entry back out by its log index.

terminal
$ rekor-cli get --log-index 84213507 \
--rekor_server https://rekor.acme.internal
output
LogID: c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801d
Index: 84213507
IntegratedTime: 2026-07-17T09:14:32Z
UUID: 24296fb24b8ad77a...f1a9
Body: {
"HashedRekordObj": {
"signature": {
"content": "MEUCIQ...",
"publicKey": { "content": "LS0tLS1CRUdJTiBD..." }
},
"data": {
"hash": { "algorithm": "sha256", "value": "3f8e...b21c" }
}
}
}

IntegratedTime is the fact that makes the whole short-lived-certificate idea work. Rekor countersigns the entry with its own key and records the moment it was included (that countersignature is the SET, a Signed Entry Timestamp). Months later, when you verify, cosign does not ask 'is this certificate valid right now.' It asks 'was this certificate valid at the moment Rekor logged the signature.' The expired certificate passes because the trusted timestamp lands inside its ten-minute window.

Verification is where the protection lives

Signing produces a claim. Verifying turns that claim into a rule you can enforce. The weak version of the rule asks only whether an image is signed by anyone at all, and that version is close to useless. The rule you actually want is narrower: is this signed by the identity you expect, vouched for by the issuer you trust? Cosign 2.x makes you spell that out. For keyless verification it refuses to run unless you pass both an identity and an issuer, precisely so nobody ships the weaker check by accident.

terminal
$ cosign verify \
--certificate-identity-regexp "https://gitlab.acme.internal/acme/.*" \
--certificate-oidc-issuer "https://gitlab.acme.internal" \
"$IMAGE@$DIGEST"
output
Verification for registry.acme.internal/payments-api@sha256:3f8e...b21c --
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/payments-api"},"image":{"docker-manifest-digest":"sha256:3f8e...b21c"},"type":"cosign container image signature"},"optional":{"1.3.6.1.4.1.57264.1.1":"https://gitlab.acme.internal","Issuer":"https://gitlab.acme.internal","Subject":"https://gitlab.acme.internal/acme/payments-api//.gitlab-ci.yml@refs/heads/main"}}]

Three checks passed, and each one blocks a different attack. 'The cosign claims were validated' means the signature matches this exact digest, so a swapped image fails. 'Existence in the transparency log' means Rekor holds the entry and its timestamp, so a backdated or forged-after-the-fact signature fails. 'The certificate was verified using trusted certificate authority certificates' means the cert really chains back to Fulcio, so a self-made cert fails. Then your two flags do the last mile: the Subject in the output has to match your identity regexp, and the Issuer has to be your GitLab. A signature from some other project, or from a public Sigstore identity, does not match, and the command exits non-zero.

A signature on a tag proves nothing
Always sign and verify $IMAGE@sha256:..., never $IMAGE:latest. A tag is a movable pointer; whoever can push to the registry can repoint it at different bytes after you signed. Cosign warns when you hand it a tag, and in CI that warning scrolls past unread. Capture the digest at build time and pass it to every downstream step. And never accept 'it has a signature' as your check. Without the identity and issuer flags, an attacker who can get any valid Sigstore certificate can produce a signature that is genuinely valid and completely worthless to you.

When the issuer is your own GitLab

One detail in those commands is doing quiet work: the issuer is https://gitlab.acme.internal, a server inside your network. The public Sigstore instance (the free 'public good' Fulcio and Rekor) only trusts a fixed list of well-known public issuers, and your internal GitLab is not on it. Signing against an internal issuer means you are running your own Sigstore: your own Fulcio configured to trust that GitLab, your own Rekor, and your own certificate transparency log. Verifiers then need your trust roots, not the public ones. You pin them once with cosign initialize, pointing at your deployment's TUF (The Update Framework, a system for securely distributing signing roots) mirror.

terminal
$ cosign initialize \
--mirror https://tuf.acme.internal \
--root /etc/sigstore/root.json
output
Root status:
{
"local": "/root/.sigstore/root",
"remote": "https://tuf.acme.internal",
"metadata": {
"root.json": { "version": 3, "len": 2597, "expiration": "14 Jan 27 00:00 UTC" },
"targets.json": { "version": 2, "len": 1789, "expiration": "14 Jan 27 00:00 UTC" }
},
"targets": [ "fulcio_v1.crt.pem", "ctfe.pub", "rekor.pub" ]
}

Skipping that step is a quiet trap. If you verify a private-Sigstore signature on a machine that never ran cosign initialize, cosign falls back to the public good trust roots, and the check fails for a reason that has nothing to do with the actual signature. That wrong-reason failure is exactly what tempts a tired engineer to reach for --insecure-ignore-tlog or a hand-fed root, which throws away the protection you just built. Distribute your root.json and run cosign initialize inside the image that does the verifying, so every check uses the same pinned Fulcio CA, Rekor key, and CT log key.

The same log that proves your good signatures also answers the security question you actually lose sleep over: did anything get signed as us that we did not expect. You can look up every entry for a known digest, and you can watch the log continuously for your identities with rekor-monitor (the official tool that walks new entries and alerts on identities you name). To see what is attached to a given image at a glance, cosign tree lists the signatures and attestations sitting beside it in the registry.

terminal
$ cosign tree "$IMAGE@$DIGEST"
output
📦 Supply Chain Security Related artifacts for an image: registry.acme.internal/payments-api@sha256:3f8e...b21c
└── 🔐 Signatures for an image tag: registry.acme.internal/payments-api:sha256-3f8e...b21c.sig
└── 🍒 sha256:9a1c...e07d
└── 💾 Attestations for an image tag: registry.acme.internal/payments-api:sha256-3f8e...b21c.att
└── 🍒 sha256:4b2f...aa16
Quick check
01You verify an image whose Rekor entry sits at log index 84213507. It was signed six months ago with a Fulcio certificate that was only good for about ten minutes, and the check passes. Which explanation fits?
Correct — Rekor countersigns the entry and freezes the moment it was logged, so the ten minute window gets judged against that time rather than against today.
Incorrect — Expiry is never waived. The log is what supplies the timestamp cosign measures the certificate against, which makes the check stricter, not looser.
Incorrect — Fulcio certificates are single use. There is no surviving key pair for a replacement certificate to bind to, so nothing is ever reissued.
Incorrect — The private half is destroyed as soon as signing finishes, so it lives in no registry object, no log entry, and no file on the runner.
02This lesson calls Sigstore signing keyless. Which description matches what cosign actually does at signing time?
Incorrect — Signing is ordinary public key cryptography. What Sigstore changes is how long the key exists, not whether one exists.
Incorrect — A guarded standing key is the exact arrangement Sigstore is built to remove. Fulcio issues identity certificates, it does not sign artifacts on your behalf.
Incorrect — The entry holds the signature, the public key and the artifact hash. Anything private would defeat the point of a log anyone can read.
Correct — An attacker who lands on that runner an hour later finds an expired certificate and nothing to sign with.
03Your Kyverno policy verifies images, but its identity regexp is written as .* and its issuer is the public Sigstore one. An attacker signs a malicious image keylessly under their own account. What does the gate do?
Incorrect — The attacker's signature is mathematically sound over their own image. Validity tells you a signing happened, never who did it.
Correct — Those two flags carry the whole trust decision. Widen them and the gate stamps anything the public instance was willing to sign.
Incorrect — Your TUF root decides which Fulcio and Rekor a verifier trusts. It has no say over who the public instance agrees to sign for.
Incorrect — Tags are a genuine trap elsewhere in this lesson, but here the attacker pushed and signed their own bytes. The gap is the identity and issuer values.

The strongest place to put the verify is not a script a developer has to remember to run. Put it in an admission controller (a gate in Kubernetes that inspects every image before a pod starts) such as the Sigstore policy-controller or Kyverno, configured with the same identity regexp and issuer you used above. Wire that in, and an image signed by the wrong project, signed with no Rekor entry, or not signed at all, never gets to run in the first place.

Try this

Run cosign sign --yes "$IMAGE@$DIGEST" on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.

Takeaway

The trap worth remembering here: a signature on a tag proves nothing. 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