Sigstore: Cosign, Fulcio & Rekor
Keyless signing and the transparency log.
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.
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.
sign-image:image: cgr.dev/chainguard/cosign:latestid_tokens:SIGSTORE_ID_TOKEN:aud: sigstorevariables:IMAGE: registry.acme.internal/payments-apiscript:# $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.
$ cosign sign --yes "$IMAGE@$DIGEST"
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: 84213507Pushing 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.
$ rekor-cli get --log-index 84213507 \--rekor_server https://rekor.acme.internal
LogID: c0d23d6ad406973f9559f3ba2d1ca01f84147d8ffc5b8445c224f98b9591801dIndex: 84213507IntegratedTime: 2026-07-17T09:14:32ZUUID: 24296fb24b8ad77a...f1a9Body: {"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.
$ cosign verify \--certificate-identity-regexp "https://gitlab.acme.internal/acme/.*" \--certificate-oidc-issuer "https://gitlab.acme.internal" \"$IMAGE@$DIGEST"
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.
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.
$ cosign initialize \--mirror https://tuf.acme.internal \--root /etc/sigstore/root.json
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.
$ cosign tree "$IMAGE@$DIGEST"
📦 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
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.