Fulcio & keyless signing
Short-lived certs bound to OIDC identity.
A signature on a software release works like a wax seal on a letter. It proves that someone holding the seal pressed it into the wax. It says nothing about whether that someone was supposed to be holding the seal at all. In December 2020, attackers who had compromised SolarWinds' build system shipped a backdoor inside a legitimately signed Orion update, and roughly 18,000 organisations installed it because the signature checked out. Now shrink that story down to your own registry. An attacker steals a push token for GHCR (GitHub Container Registry) and publishes ghcr.io/acme/api:latest with a reverse shell baked in. Your cluster pulls latest, the image is there, nothing looks wrong. Pinning a digest will not save you if you resolved a tag to get it, and an old-fashioned signature will not save you either if the attacker also holds your signing key. When that key lives in a CI (continuous integration) secret, they very well might. Keyless signing exists to answer one question: did this artifact come out of acme/api's release pipeline, and out of nothing else?
The trouble with keys you have to keep forever
Old-style signing (GPG, short for GNU Privacy Guard, and X.509 code-signing certificates, the same certificate format your browser uses for HTTPS) hands you a private key and a permanent chore list. Generate it, store it, protect it, rotate it, revoke it, and keep doing that for as long as the project lives. Most teams did one of two things. They never signed anything at all, or they pasted the key into a CI secret where anybody with pipeline access, or one leaked token, could walk off with a copy. A stolen signing key is a quiet catastrophe. It keeps producing perfectly valid signatures until somebody happens to notice, and revocation is slow and almost never wired into the tools doing the checking. Keyless signing, which came out of the Sigstore project, deletes the durable secret instead of trying to guard it. Fulcio is its certificate authority (CA, the service whose job is to issue certificates vouching for who somebody is). It issues a short-lived X.509 code-signing certificate that ties a throwaway public key to an identity you already have: a CI workflow, a corporate single sign-on account. You prove that identity with an OpenID Connect (OIDC) token, the same kind of identity token you hand over when a site lets you log in with your Google or GitHub account. The matching private key lives for a few seconds and is then thrown away, so nothing durable is left lying around to steal.
What actually happens when a CI job signs
Seven steps, start to finish. (1) The runner asks its provider for an OIDC identity token. On GitHub Actions you get one by setting permissions: id-token: write, and the token's subject spells out the exact repository, workflow file and git ref. (2) cosign generates a fresh ECDSA P-256 key pair (a standard elliptic-curve keypair) in memory. (3) It sends the OIDC token and that throwaway public key to Fulcio. Fulcio checks the token's signature against the issuer's published keys, reads the identity out of it, and issues a certificate whose Subject Alternative Name (SAN, the field that carries the certificate's real subject) is that identity, wrapped around your throwaway public key. (4) Before handing the certificate back, Fulcio publishes it to public Certificate Transparency (CT) logs, the append-only ledgers browsers already rely on to catch mis-issued web certificates, and embeds the Signed Certificate Timestamps (SCTs) it gets back. An SCT is a receipt proving the certificate was logged in public. (5) cosign signs the artifact's digest with the throwaway private key. (6) The certificate, the signature and the digest are appended to Rekor, Sigstore's transparency log. (7) The private key is destroyed. The certificate is good for ten minutes, which is long enough to sign something and far too short to be worth stealing.
permissions:id-token: write # mint the OIDC token Fulcio will verifypackages: write # push image + signature to GHCRsteps:- uses: sigstore/[email protected]- name: Sign the image by digest (keyless)run: cosign sign --yes "ghcr.io/acme/api@${DIGEST}"env:DIGEST: ${{ steps.build.outputs.digest }}
Generating ephemeral keys...Retrieving signed certificate...Successfully verified SCT...tlog entry created with index: 149238471Pushing signature to: ghcr.io/acme/api
Verify by identity, not by key
Verification runs the flow backwards, and this is where nearly all of the security actually lives. cosign verify makes you say which identity you are willing to accept, both the OIDC issuer and the certificate subject, and it fails closed if you leave either out. Where does cosign get its own root of trust? From the Sigstore TUF root. The Update Framework (TUF) is a signed, versioned way of shipping trusted metadata around, and it distributes Fulcio's CA certificate, the CT log keys and Rekor's public key, so those roots can rotate without anyone releasing a new cosign binary. With that root loaded, verify checks four things. The certificate chains back to Fulcio. The embedded SCT proves the certificate was logged in public, so a rogue Fulcio cannot mint certificates for your identity in secret. The signature matches the artifact digest. And the SAN identity matches the policy you wrote. All four have to hold. Only when the SAN reads, say, acme/api's release workflow running on a version tag does verification pass.
cosign verify \--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \--certificate-identity-regexp '^https://github\.com/acme/api/\.github/workflows/release\.yml@refs/tags/v.*$' \ghcr.io/acme/api@sha256:8f2e...c91 | jq .
Verification for ghcr.io/acme/api@sha256:8f2e...c91 --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": "ghcr.io/acme/api" },"image": { "docker-manifest-digest": "sha256:8f2e...c91" },"type": "cosign container image signature"},"optional": {"1.3.6.1.4.1.57264.1.1": "https://token.actions.githubusercontent.com","Bundle": { "Payload": { "logIndex": 149238471, "integratedTime": 1784139730 } },"Issuer": "https://token.actions.githubusercontent.com","Subject": "https://github.com/acme/api/.github/workflows/release.yml@refs/tags/v1.4.2"}}]
Crack one of these certificates open
The certificate carries the whole scheme, so pull one apart and read it. cosign download signature fetches the signature object out of the registry, its Cert field holds the PEM (the base64 text form of a certificate), and openssl x509 -text turns that into something a human can read. Three details stand out. The Subject DN (distinguished name, the traditional 'who is this' field) is empty, because the identity is not kept there. It lives in the Subject Alternative Name, marked critical, holding the URI of the workflow that did the signing. Next comes a block of Fulcio extensions under the 1.3.6.1.4.1.57264 OID arc. An OID (object identifier) is a globally unique dotted number, and that one is Sigstore's own IANA private-enterprise number. Those extensions record the OIDC issuer, the source repository, the workflow ref, the commit digest and what triggered the build, which is machine-readable provenance baked straight into the certificate. The CT Precertificate SCTs extension carries the signed receipts from the transparency logs. And the Validity window gives the game away: Not Before and Not After sit exactly ten minutes apart.
cosign download signature ghcr.io/acme/api@sha256:8f2e...c91 \| jq -r '.Cert' \| openssl x509 -noout -text
Certificate:Data:Version: 3 (0x2)Signature Algorithm: ecdsa-with-SHA384Issuer: O = sigstore.dev, CN = sigstore-intermediateValidityNot Before: Jul 15 18:22:07 2026 GMTNot After : Jul 15 18:32:07 2026 GMT # <-- 10 minutesSubject: # <-- empty; identity is in the SANSubject Public Key Info:Public Key Algorithm: id-ecPublicKeyASN1 OID: prime256v1NIST CURVE: P-256X509v3 extensions:X509v3 Key Usage: criticalDigital SignatureX509v3 Extended Key Usage:Code SigningX509v3 Subject Alternative Name: criticalURI:https://github.com/acme/api/.github/workflows/release.yml@refs/tags/v1.4.21.3.6.1.4.1.57264.1.1: # OIDC issuer (v1)https://token.actions.githubusercontent.com1.3.6.1.4.1.57264.1.12: # source repository URI..https://github.com/acme/api1.3.6.1.4.1.57264.1.13: # source commit digest..a1b2c3d4e5f6...1.3.6.1.4.1.57264.1.14: # source repository ref..refs/tags/v1.4.21.3.6.1.4.1.57264.1.20: # build trigger..pushCT Precertificate SCTs:Signed Certificate Timestamp:Version : v1 (0x0)Log ID : DD:3D:30:6A:C6:C7:11:32:63:19:1E:1C:99:...Timestamp : Jul 15 18:22:07.512 2026 GMTSignature : ecdsa-with-SHA25630:45:02:21:00:...Signature Algorithm: ecdsa-with-SHA38430:64:02:30:...
So why is a certificate that expired months ago still worth anything, and why is there no revocation list to consult? Two mechanisms carry that weight. The SCT is Certificate Transparency applied to code signing: Fulcio has to publish every certificate it issues to append-only CT logs, and the embedded SCT is the proof that it did. A verifier that insists on a valid SCT will reject any certificate that never showed up in public, which means a compromised or coerced Fulcio cannot quietly issue certificates in your name without leaving evidence anyone can watch for. The ten-minute lifetime then deletes the revocation problem rather than solving it. The private key is already gone, so there is no standing risk sitting out there to revoke, and a stolen certificate is worthless within minutes anyway. What proves the certificate was valid at the moment it signed, long after it expired, is Rekor's countersigned timestamp. The certificate's short life is safe precisely because a durable, tamper-evident log vouches for the instant it was used.
Fulcio leaves you with a signature tied to an identity you can name in a policy, and a certificate that self-destructs ten minutes later. Which raises the question this lesson has been deferring: if the certificate is long gone, what lets you check a release that was signed six months ago? The answer is the append-only, Merkle-tree-backed record that cosign wrote in step six. That is Rekor, the transparency log, and it is where the next lesson starts.
Try this
Work through “Crack one of these certificates open” 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: an unanchored identity regex pins nothing at all. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.