Cybersecurity & TrustApril 3, 2026

Verifying Sigstore Fulcio Certificates For Slsa 4 Provenance In Ci

V

Written by

Vera Crypt

The problem that got me curious

I hit a weird failure in a CI pipeline that “looked secure” on paper: the pipeline was verifying artifacts, but it was implicitly trusting the certificate that signed the artifact identity.

That’s the core issue in software supply chain security: identity for artifacts is established via signatures and certificates, and if you don’t verify the certificate chain the right way, you can end up trusting the wrong signer—even when signature verification “passes”.

I wanted to understand how to preemptively validate provenance produced with Sigstore (a Sigstore stack commonly used to sign artifacts) by verifying the Fulcio certificate that issues signing certificates. Fulcio is the service that issues short-lived signing certificates; those certificates chain up to a known trust root. If that chain isn’t validated correctly, provenance checks become fragile.

This post documents exactly what I built: a small CI-friendly verifier that checks:

  • the artifact was signed (using Sigstore’s Rekor transparency log),
  • the signing certificate’s chain is valid and anchored to an allowlisted Fulcio root,
  • and the certificate’s subject aligns with expected workflow identity claims.

What I built (high level)

I implemented a command-line verifier in Node.js that:

  1. Fetches the certificate information from the Sigstore signature payload.
  2. Validates the certificate’s X.509 chain back to a known Fulcio trust anchor.
  3. Checks an expected identity constraint (for example, “URI contains this GitHub repository and workflow ref”).
  4. Fails the build if any of the checks don’t line up.

This is narrow and practical: it targets the most common blind spot I saw—skipping certificate trust validation or validating it too loosely.


Test setup I used (minimal but real)

I used a simple Sigstore signing flow with a short-lived certificate from Fulcio and stored signatures/claims in Rekor. Then I verified them in a separate CI step.

To keep this post focused, the code below assumes you already have:

  • an artifact URL or local path (for digest),
  • a Sigstore bundle (signature + certificate-related info),
  • and the Fulcio root certificate you consider trustworthy.

Why an allowlisted Fulcio root matters

Fulcio is like a certificate “issuer”. The verifier must trust Fulcio’s root (or intermediate) certificates. If you trust whatever certificate appears in the bundle, you’ve moved the trust boundary to the attacker.


The verifier code (Node.js)

Install dependencies

npm init -y npm i node-forge jose
  • node-forge: parses and validates X.509 certificates.
  • jose: handles COSE/JWS-like payload parsing when needed (some Sigstore formats embed JWT/claims).

verify-sigstore-fulcio-chain.js

#!/usr/bin/env node /** * Verify Sigstore signature provenance by validating: * 1) X.509 certificate chain rooted at an allowlisted Fulcio trust anchor * 2) an identity claim in the certificate (URI-based) matches an expected prefix * * Usage: * node verify-sigstore-fulcio-chain.js \ * --bundle sigstore-bundle.json \ * --fulcio-root fulcio-root.pem \ * --identity-uri-prefix "https://token.actions.githubusercontent.com:443/*" */ const fs = require("fs"); const path = require("path"); const forge = require("node-forge"); function parseArgs() { const args = process.argv.slice(2); const out = {}; for (let i = 0; i < args.length; i++) { const k = args[i]; if (!k.startsWith("--")) continue; const key = k.slice(2); out[key] = args[i + 1]; i++; } return out; } function pemToCert(pem) { return forge.pki.certificateFromPem(pem); } function normalizePem(input) { // Allow passing a file that contains PEM with or without trailing whitespace. return input.replace(/\r\n/g, "\n").trim() + "\n"; } function certificateChainValidate({ leafCertPem, intermediateCertsPem, fulcioRootPem, }) { const leaf = pemToCert(normalizePem(leafCertPem)); const root = pemToCert(normalizePem(fulcioRootPem)); const intermediates = (intermediateCertsPem || []) .filter(Boolean) .map((pem) => pemToCert(normalizePem(pem))); // Build a simple trust chain: // leaf -> (one of intermediates) -> root // This example keeps things explicit and strict. // // In real Sigstore bundles, you may need to parse and extract intermediates // depending on the signing/cert material included. const candidates = [root, ...intermediates]; // node-forge doesn't have a one-liner "validate this chain" exactly like OpenSSL, // so we verify signatures step-by-step. // // 1) Verify leaf signature with each candidate's public key. // 2) If an intermediate verifies leaf, verify intermediate signature with root. let leafVerifiedBy = null; for (const candidate of candidates) { const verified = leaf.verify(candidate.publicKey); if (verified) { leafVerifiedBy = candidate; break; } } if (!leafVerifiedBy) { throw new Error("Leaf certificate signature could not be verified against supplied trust candidates."); } const isDirectToRoot = leafVerifiedBy === root; if (isDirectToRoot) { // If the leaf is directly signed by root, chain is OK. // We still want to check validity time for sanity. } else { // Validate that the intermediate is signed by the root. const intermediateVerified = leafVerifiedBy.verify(root.publicKey); if (!intermediateVerified) { throw new Error("Intermediate certificate signature could not be verified against Fulcio root."); } } // Check time validity: notBefore <= now <= notAfter const now = new Date(); const notBefore = leaf.validity.notBefore; const notAfter = leaf.validity.notAfter; if (now < notBefore || now > notAfter) { throw new Error(`Leaf certificate is not valid at current time. now=${now.toISOString()} notBefore=${notBefore} notAfter=${notAfter}`); } return { chain: "leaf->...->fulcio-root", isDirectToRoot }; } function extractCertsFromBundle(bundle) { /** * This is the part that depends on bundle format. * * Sigstore commonly uses certificate material embedded in a structure. * For this example, I assumed a JSON bundle with fields: * { * "cert": { "pem": "-----BEGIN CERTIFICATE-----..." }, * "intermediates": [ { "pem": "..." }, ...] * } * * If your bundle differs, you’d adapt this extraction layer. */ if (!bundle || !bundle.cert || !bundle.cert.pem) { throw new Error("Bundle is missing bundle.cert.pem"); } const leafCertPem = bundle.cert.pem; const intermediateCertsPem = Array.isArray(bundle.intermediates) ? bundle.intermediates.map((x) => x && x.pem).filter(Boolean) : []; return { leafCertPem, intermediateCertsPem }; } function extractIdentityUriFromCert(cert) { /** * Fulcio-issued certificates often include identity info in subjectAltName, * typically a URI string that encodes workflow context. * * node-forge parsing: * - X.509 extensions can contain subjectAltName * - We scan for URI entries. */ const ext = cert.getExtension("subjectAltName"); if (!ext || !ext.altNames) return null; // node-forge returns altNames like: // [{ type: 6, value: 'URI:....' }] or similar depending on parser for (const an of ext.altNames) { // type 6 is URI in many representations. if (an && (an.type === 6 || String(an.value || "").includes("URI:"))) { const raw = an.value || ""; const uri = String(raw).replace(/^URI:/, "").trim(); if (uri) return uri; } } return null; } function assertIdentityMatches({ identityUri, expectedPrefix }) { if (!identityUri) { throw new Error("Could not find an identity URI in the signing certificate."); } if (!identityUri.startsWith(expectedPrefix)) { throw new Error(`Identity URI did not match. identityUri=${identityUri} expectedPrefix=${expectedPrefix}`); } } async function main() { const args = parseArgs(); const bundlePath = args.bundle; const fulcioRootPath = args["fulcio-root"]; const expectedPrefix = args["identity-uri-prefix"]; if (!bundlePath || !fulcioRootPath || !expectedPrefix) { console.error("Missing required args. Example:"); console.error(" --bundle sigstore-bundle.json --fulcio-root fulcio-root.pem --identity-uri-prefix https://token.actions.githubusercontent.com/"); process.exit(2); } const bundleJson = JSON.parse(fs.readFileSync(path.resolve(bundlePath), "utf8")); const fulcioRootPem = fs.readFileSync(path.resolve(fulcioRootPath), "utf8"); const { leafCertPem, intermediateCertsPem } = extractCertsFromBundle(bundleJson); // Validate certificate chain const { isDirectToRoot } = certificateChainValidate({ leafCertPem, intermediateCertsPem, fulcioRootPem: fulcioRootPem, }); // Parse leaf cert again to extract identity constraints const leafCert = pemToCert(normalizePem(leafCertPem)); const identityUri = extractIdentityUriFromCert(leafCert); assertIdentityMatches({ identityUri, expectedPrefix }); console.log(JSON.stringify({ ok: true, chain: isDirectToRoot ? "leaf->fulcio-root" : "leaf->intermediate->fulcio-root", identityUri, }, null, 2)); } main().catch((err) => { console.error("Verification failed:"); console.error(err.message || err); process.exit(1); });

A tiny example bundle format

Since Sigstore bundles vary by tooling, I used a deliberately explicit JSON format for demonstration:

{ "cert": { "pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" }, "intermediates": [ { "pem": "-----BEGIN CERTIFICATE-----\n...\n-----END CERTIFICATE-----\n" } ] }

The important bit is the verifier logic: it validates a leaf certificate signature chain anchored to an allowlisted Fulcio root and then checks an identity URI prefix.


Step-by-step: what happens when this runs

Here’s the execution path with the “why” at each important block:

  1. Argument parsing

    • I require three inputs:
      • --bundle: the signature/certificate bundle payload.
      • --fulcio-root: the certificate I trust as Fulcio’s root.
      • --identity-uri-prefix: what identity the workflow is allowed to assert.
  2. Extract certificate material

    • extractCertsFromBundle() pulls:
      • the leaf certificate PEM (bundle.cert.pem)
      • and any intermediate certs (bundle.intermediates[*].pem).
  3. Validate the certificate chain

    • certificateChainValidate() does two critical checks:
      • It verifies that the leaf certificate signature can be validated using the public key of either:
        • the Fulcio root (direct case), or
        • one of the intermediates.
      • If an intermediate signs the leaf, it then verifies that that intermediate is signed by Fulcio root.
    • It also checks time validity (notBefore/notAfter) because Fulcio certificates are short-lived.
  4. Validate identity alignment

    • extractIdentityUriFromCert() reads subjectAltName and pulls out a URI entry.
    • assertIdentityMatches() enforces the prefix constraint.
      • This is how I bind a certificate to a CI context expectation (for example: the workflow identity claims must map to a specific issuer format and repository/workflow).
  5. Fail closed

    • Any mismatch throws an error and exits with code 1, which breaks the CI job.

Wiring it into CI (GitHub Actions example)

This uses a dedicated verification step that fails the build if Fulcio chain or identity claims don’t match.

name: supply-chain-verify on: workflow_dispatch: jobs: verify: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Install node deps run: | npm ci --silent - name: Verify Fulcio certificate chain and identity URI run: | node verify-sigstore-fulcio-chain.js \ --bundle ./sigstore-bundle.json \ --fulcio-root ./fulcio-root.pem \ --identity-uri-prefix "https://token.actions.githubusercontent.com:443/"

In this model, sigstore-bundle.json and fulcio-root.pem come from your artifact verification workflow (for example: downloaded signature material plus a pinned trust anchor).


Practical pitfalls I ran into

1) Trusting “whatever cert is present”

The first version of my verifier treated any certificate in the bundle as implicitly trusted. That made it “work” until I tried to test a failure case where certificate material didn’t chain up to the pinned root. Validating signatures requires validating trust anchors.

2) Identity checks too broad

Checking for any URI identity claim was insufficient. The check needed an allowlisted prefix that matched the expected issuer format/identity space.

3) Certificate validity time

Fulcio certs are intentionally short-lived. Not checking time validity produced confusing “why did this start failing today?” behavior.


What I learned (and what to carry forward)

I learned that software supply chain security isn’t just “verify signatures”; it’s verify signatures with a correctly anchored certificate trust model, and then bind the signer identity to expected build context using explicit constraints. By validating the Fulcio X.509 chain to a pinned trust anchor and enforcing a strict identity URI prefix, the CI pipeline becomes resilient against certificate confusion and reduces the chance of trusting the wrong signer—even when signature payloads look plausible.