Skip to content
ecdsa.com
← All articles

Signing releases with Sigstore: cosign, bundles, identity

Sigstore replaces long-lived signing keys with a ten-minute certificate tied to a CI workflow or an email, plus a public log entry. The signature is easy; the part people get wrong is verification. Here is the whole flow, verified against real releases on 20 October 2026.

Short answer. With Sigstore "keyless" signing, the signer proves an identity with an OIDC token (a GitHub Actions workflow, a Google or Microsoft account). Fulcio issues a certificate for an ephemeral key, valid for about ten minutes, with that identity inside. The signature is recorded in the public Rekor transparency log, and everything needed to verify ends up in a bundle (.sigstore.json). Verification has to check who signed, not just that the signature is valid. In cosign that means --certificate-identity and --certificate-oidc-issuer, which cosign requires in keyless mode. npm provenance and GitHub artifact attestations are the same machinery: npm audit signatures and gh attestation verify check them for you.

Why keyless

Classic release signing with GPG or a long-lived code-signing key has two chronic problems. Users have to know which key is the right one, so key distribution is the actual trust problem. And the key must be stored somewhere for years, usually a CI secret, where it can leak. Sigstore replaces the key with an identity people already understand, such as "the release workflow of repository X" or "alice@example.com". It replaces the key's lifetime with a log entry that proves when the signature was made.

Sigstore consists of public services run by the OpenSSF community (the "public-good instance") and client tools:

  • Fulcio is a certificate authority that issues short-lived X.509 certificates for OIDC identities.
  • Rekor is the transparency log for signatures and attestations.
  • TUF metadata distributes the roots of trust (Fulcio's CA, Rekor's key, timestamp authorities) to clients.
  • cosign is the CLI for blobs and container images. sigstore-python, sigstore-js, sigstore-go and sigstore-java are libraries for the same tasks.

What happens when you sign

  1. The client creates an ephemeral key pair in memory.
  2. It gets an OIDC token: in GitHub Actions from the runner (id-token: write), on a laptop through a browser login.
  3. Fulcio checks the token and issues a certificate binding the public key to the identity: the email or workflow URI goes into the Subject Alternative Name, the issuer and CI details into extensions.
  4. The client signs the artifact's digest and submits signature and certificate to Rekor, which returns a log entry with an inclusion proof and a signed timestamp.
  5. The ephemeral private key is discarded. The bundle stores certificate, signature and log evidence.

The certificate in the bundle that cosign ships with its own v3.1.3 release shows how short-lived it is:

openssl x509 on the certificate inside cosign-linux-arm64.sigstore.json
issuer=O=sigstore.dev, CN=sigstore-intermediate
notBefore=Aug  6 01:03:25 2026 GMT
notAfter=Aug  6 01:13:25 2026 GMT
X509v3 Extended Key Usage:
    Code Signing
X509v3 Subject Alternative Name: critical
    email:keyless@projectsigstore.iam.gserviceaccount.com
1.3.6.1.4.1.57264.1.1:
https://accounts.google.com

Ten minutes of validity. The signature stays verifiable after that, because the Rekor entry proves it was made inside that window. The Rekor integratedTime of this entry is 2026-08-06 01:03:25 UTC.

The bundle

A bundle is a JSON document that contains everything a verifier needs except the artifact and the trust roots. This is the cosign release bundle, abridged:

cosign-linux-arm64.sigstore.json (v3.1.3 release asset, values shortened)
{
  "mediaType": "application/vnd.dev.sigstore.bundle.v0.3+json",
  "verificationMaterial": {
    "certificate": { "rawBytes": "MIIDDzCCApWgAwIBAgIUavXJ..." },
    "tlogEntries": [
      {
        "logIndex": "2352242192",
        "kindVersion": { "kind": "hashedrekord", "version": "0.0.1" },
        "integratedTime": "1785978205",
        "inclusionPromise": { "signedEntryTimestamp": "MEYCIQCvddr55BoX..." },
        "inclusionProof": { "logIndex": "2230337930", "treeSize": "2230337938", "rootHash": "62m7TRzwvcKyu+r6...", "hashes": ["..."], "checkpoint": { "envelope": "..." } },
        "canonicalizedBody": "eyJhcGlWZXJzaW9u..."
      }
    ],
    "timestampVerificationData": { "rfc3161Timestamps": ["..."] }
  },
  "messageSignature": {
    "messageDigest": { "algorithm": "SHA2_256", "digest": "xdMk4JGCaw16eOsW..." },
    "signature": "MEUCIECQp5FyO00l..."
  }
}
  • certificate is the Fulcio leaf, with the identity.
  • tlogEntries holds the Rekor entry: an inclusion proof against a signed checkpoint and a signed entry timestamp. With these, a verifier does not need to contact Rekor at all.
  • timestampVerificationData holds optional RFC 3161 timestamps from a timestamp authority.
  • The content is either messageSignature for a plain blob signature or dsseEnvelope for an attestation, a signed in-toto statement.

The Sigstore bundle verifier checks all of these parts in the browser and shows the identity in the certificate, which is the first thing to look at when a verification fails.

Verifying: identity is the point

cosign verifying its own release binary (ghcr.io/sigstore/cosign/cosign:v3.1.3, 20 October 2026). The release is built on Google infrastructure, so the identity is a Google service account and the issuer is Google, not GitHub:

cosign verify-blob: the expected identity
$ cosign verify-blob cosign-linux-arm64 --bundle cosign-linux-arm64.sigstore.json \
    --certificate-identity keyless@projectsigstore.iam.gserviceaccount.com \
    --certificate-oidc-issuer https://accounts.google.com
Verified OK

Now the failure cases. Each one has a message that tells you exactly which check failed:

cosign verify-blob: what failure looks like
--- right email, wrong issuer
error during command execution: failed to verify certificate identity: no matching CertificateIdentity found, last error: expected issuer value "https://token.actions.githubusercontent.com", got "https://accounts.google.com"
--- a different identity
error during command execution: failed to verify certificate identity: no matching CertificateIdentity found, last error: expected SAN value "release@example.com", got "keyless@projectsigstore.iam.gserviceaccount.com"
--- no identity at all
error during command execution: --certificate-identity or --certificate-identity-regexp is required for verification in keyless mode
--- a modified binary
error during command execution: failed to verify signature: could not verify message: invalid signature when validating ASN.1 encoded signature

The third case is deliberate design. Anyone can get a Fulcio certificate for their own identity and sign anything. A signature is only meaningful once you check that the identity is the one you expect. cosign refuses to verify keyless signatures without an identity (error page). The last case is what tampering looks like (error page).

Where do you get the expected identity? From the project's documentation, or by reading it once from a bundle you trust and pinning it in your verification script. --certificate-identity-regexp is useful for tags (for example ...release.yml@refs/tags/v.*), but anchor it at both ends. A pattern like .*sigstore.* matches identities that anyone can create.

Signing in GitHub Actions

In CI the identity is the workflow. Fulcio puts the workflow file and ref into the SAN, and the details into extensions. The npm provenance bundle of sigstore@5.0.0 shows what a GitHub Actions certificate carries, decoded with a small script from its X.509 extensions:

Fulcio extensions in the npm provenance certificate of sigstore@5.0.0
1.3.6.1.4.1.57264.1.8    Issuer (V2)              https://token.actions.githubusercontent.com
1.3.6.1.4.1.57264.1.9    Build Signer URI         https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main
1.3.6.1.4.1.57264.1.12   Source Repository URI    https://github.com/sigstore/sigstore-js
1.3.6.1.4.1.57264.1.13   Source Repository Digest 7d2900eca1c22b3f87c13987c8d4b7c9a29b733a
1.3.6.1.4.1.57264.1.14   Source Repository Ref    refs/heads/main
1.3.6.1.4.1.57264.1.18   Build Config URI         https://github.com/sigstore/sigstore-js/.github/workflows/release.yml@refs/heads/main
1.3.6.1.4.1.57264.1.20   Build Trigger            push
1.3.6.1.4.1.57264.1.21   Run Invocation URI       https://github.com/sigstore/sigstore-js/actions/runs/26781866618/attempts/1

cosign can check these claims too, in addition to the SAN:

cosign verify-blob-attestation with GitHub claims (cosign v3.1.3)
$ cosign verify-blob-attestation --bundle provenance.sigstore.json --type slsaprovenance1 \
    --certificate-identity-regexp '^https://github\.com/sigstore/sigstore-js/\.github/workflows/release\.yml@' \
    --certificate-oidc-issuer https://token.actions.githubusercontent.com \
    --certificate-github-workflow-repository sigstore/sigstore-js \
    --certificate-github-workflow-trigger push \
    sigstore-5.0.0.tgz
Verified OK

To sign your own releases in GitHub Actions, the job needs permission to request an OIDC token. GitHub's actions/attest README gives this minimal setup for build provenance of a file:

GitHub Actions: build provenance for a release file (from the actions/attest README)
permissions:
  id-token: write
  attestations: write
  artifact-metadata: write

steps:
  # ... build steps that produce dist/app.tar.gz ...
  - uses: actions/attest@v4
    with:
      subject-path: 'dist/app.tar.gz'

Public repositories use the public-good Sigstore instance. Private repositories use GitHub's own instance, which requires GitHub Enterprise Cloud. actions/attest-build-provenance still works, but since v4 it is only a wrapper around actions/attest. To sign a blob with cosign directly in the job, the command is cosign sign-blob --bundle app.sigstore.json app.tar.gz --yes. cosign's --oidc-provider help lists github-actions among the token sources it tries when none is set. We did not run a keyless signing for this page, because every keyless signature on the public instance is a permanent, public log entry.

A signature from a workflow is only as trustworthy as the workflow. Anyone who can push to the branch, or trigger the workflow, can produce a valid signature with that identity. Protect release workflows with branch and tag protection, required reviews and environment approvals, and pin verification to a tag ref (@refs/tags/...) when you release from tags.

npm provenance

npm publishes two kinds of Sigstore evidence for packages built in supported CI: a publish attestation signed by the registry and a SLSA provenance attestation signed by the build. npm audit signatures checks both for everything installed:

npm 11.19.1: npm audit signatures (sigstore@5.0.0 and semver@7.7.2 with dependencies)
$ npm audit signatures
audited 52 packages in 2s

52 packages have verified registry signatures

26 packages have verified attestations
(use --json --include-attestations to view attestation details)

The attestations for one version are public, at https://registry.npmjs.org/-/npm/v1/attestations/<name>@<version>. For sigstore@5.0.0:

Attestations of sigstore@5.0.0, decoded
https://github.com/npm/attestation/tree/main/specs/publish/v0.1 | bundle application/vnd.dev.sigstore.bundle+json;version=0.2
   subject: pkg:npm/sigstore@5.0.0 849a897e81bf7b8a8541a6ae...
https://slsa.dev/provenance/v1 | bundle application/vnd.dev.sigstore.bundle.v0.3+json
   subject: pkg:npm/sigstore@5.0.0 849a897e81bf7b8a8541a6ae...
   workflow: https://github.com/sigstore/sigstore-js .github/workflows/release.yml refs/heads/main
   builder: https://github.com/actions/runner/github-hosted
   commit: 7d2900eca1c22b3f87c13987c8d4b7c9a29b733a

The subject digest is the SHA-512 of the tarball (849a897e...), so the provenance bundle can be verified against the downloaded .tgz with cosign, as shown above. With npm's trusted publishing (npm CLI 11.5.1 or later), packages published from GitHub Actions or GitLab CI through OIDC get provenance automatically, without the --provenance flag and without a long-lived npm token. CircleCI is supported for trusted publishing but not yet for provenance. Outside a supported CI, npm publish --provenance fails with Automatic provenance generation not supported for provider: null. The npm provenance checker shows the repository, workflow and commit for any package version.

GitHub artifact attestations

GitHub stores attestations for release assets and makes them available through its API. The gh CLI release tarball gh_2.102.0_linux_arm64.tar.gz (SHA-256 7862c86c...) had two attestations on 20 October 2026: SLSA provenance from the cli/cli deployment workflow, and a release attestation signed by GitHub itself. With the provenance bundle saved locally, gh attestation verify works without logging in and prints the policy it enforced:

gh 2.102.0: gh attestation verify with a local bundle (colours removed)
$ gh attestation verify gh_2.102.0_linux_arm64.tar.gz --repo cli/cli --bundle gh-1.sigstore.json
Loaded digest sha256:7862c86c72f43df3a2d93ddde6f473285b4e2af61b494849846827e513ef6484 for file:///d/gh_2.102.0_linux_arm64.tar.gz
Loaded 1 attestations from /d/gh-1.sigstore.json

The following policy criteria will be enforced:
- Predicate type must match:................ https://slsa.dev/provenance/v1
- Source Repository Owner URI must match:... https://github.com/cli
- Source Repository URI must match:......... https://github.com/cli/cli
- Subject Alternative Name must match regex: (?i)^https://github\.com/cli/cli/
- OIDC Issuer must match:................... https://token.actions.githubusercontent.com

✓ Verification succeeded!

The following 1 attestation matched the policy criteria

- Attestation #1
  - Build repo:..... cli/cli
  - Build workflow:. .github/workflows/deployment.yml@refs/heads/trunk
  - Signer repo:.... cli/cli
  - Signer workflow: .github/workflows/deployment.yml@refs/heads/trunk

With --repo example/cli the same check ends in ✗ Sigstore verification failed. The default policy (owner, repository, SAN prefix) is a good start. For release automation, add --signer-workflow to pin the exact workflow file, and --deny-self-hosted-runners if your builds run only on GitHub-hosted runners. The same bundle also verifies with cosign: cosign verify-blob-attestation --type slsaprovenance1 with the SAN https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk printed Verified OK.

The second attestation is different. Its predicate type is https://in-toto.io/attestation/release/v0.2. The certificate names URI:https://dotcom.releases.github.com and was issued by O=GitHub, Inc., CN=Fulcio Intermediate l1, GitHub's own Fulcio, with an RFC 3161 timestamp instead of a Rekor entry. GitHub creates it for repositories with immutable releases enabled. It proves the asset belongs to the release, not how it was built. gh release verify and gh release verify-asset check it. In our test gh release verify-asset needed a logged-in gh: without a token, gh 2.102.0 stopped with To get started with GitHub CLI, please run: gh auth login.

When keyless does not fit

Air-gapped builds, private artifacts that must not appear in a public log, or policies that require a key in an HSM: cosign also signs with keys, and the log upload can be switched off. Tested with cosign v3.1.3:

cosign with a key pair and no transparency log
$ cosign generate-key-pair
Private key written to cosign.key
Public key written to cosign.pub
$ cosign sign-blob --key cosign.key --tlog-upload=false --use-signing-config=false --bundle app.sigstore.json app-1.0.0.tar.gz --yes
Using payload from: app-1.0.0.tar.gz
Signing artifact...
Wrote bundle to file app.sigstore.json
$ cosign verify-blob --key cosign.pub --bundle app.sigstore.json --insecure-ignore-tlog=true app-1.0.0.tar.gz
WARNING: Skipping tlog verification is an insecure practice that lacks transparency and auditability verification for the blob.
Verified OK
$ cosign verify-blob --key cosign.pub --bundle app.sigstore.json app-1.0.0.tar.gz
error during command execution: failed to verify log inclusion: not enough verified log entries from transparency log: 0 < 1

The bundle then contains only a publicKey hint, and you are back to distributing and protecting a long-lived key, the problem keyless signing removes. For a private but still auditable setup, run your own Sigstore instance (Fulcio, Rekor, TUF) or use a vendor's. GitHub does this for private repositories.

The state of the infrastructure in 2026

  • Rekor v2, a tile-based log, reached general availability in October 2025. Verifiers need cosign 2.6.0 or 3.0.1 and later. Rekor v2 dropped the search index.
  • In June 2026 the maintainers announced that the public-good instance "will continue using Rekor v1 as the default log for the foreseeable future". Rekor v2 stays in production for ecosystems that opt in.
  • A separate post-quantum Sigstore with ML-DSA is "targeted for mid-2027", per the October 2026 announcement. The classical instance is to keep running until a turndown "tentatively scheduled for 2029 or 2030". Bundles stay the format either way.

The verification side barely changes: keep your clients current, verify from bundles, and pin identities, not log URLs.

Common mistakes

  • Checking the signature but not the identity. A valid Sigstore signature only says someone signed.
  • Over-broad identity patterns. Unanchored regular expressions, or an issuer check without a SAN check.
  • Pinning to a branch when you release from tags. @refs/heads/main accepts anything built from main, including a build nobody meant to release.
  • Assuming signing proves the build. A blob signature proves who signed the bytes. Provenance (SLSA, in-toto) says how and from what they were built. Verify the one your policy needs.
  • Signing personal releases with a personal email without realising that the email is in the certificate and therefore in a public, permanent log.
  • Shipping detached .sig and .pem files from older cosign versions. Publish the .sigstore.json bundle. It is the format all current verifiers understand.

Sources

Verification ran on 20 October 2026 in ghcr.io/sigstore/cosign/cosign:v3.1.3, with npm 11.19.1 and gh 2.102.0 (the official release tarball) in node:24.22.0-trixie.