Verifying SLSA Build Provenance in CI

Permalink to "Verifying SLSA Build Provenance in CI"

Part of Provenance Verification Workflows, this page shows how to make a pipeline refuse an artifact whose provenance does not check out — the exact commands, their PASSED and FAILED output, and the CI job that gates on them.

Quick Reference

Permalink to "Quick Reference"
Command / flag Purpose Notes
slsa-verifier verify-artifact <file> Verify a downloaded binary or tarball Requires --provenance-path
--provenance-path <file>.intoto.jsonl The attestation bundle to check against Usually published beside the artifact
--source-uri github.com/org/repo Pin the source repository No scheme, no trailing slash
--source-tag v1.4.0 Pin the released tag Use --source-branch for branch builds
--builder-id <workflow URI> Pin the exact builder workflow Omit it and any trusted builder is accepted
--print-provenance Emit the verified statement on stdout Pipe to jq to read the commit SHA
slsa-verifier verify-image <img>@sha256:… Verify a container image Digest form only, never a tag
gh attestation verify <file> --repo org/repo Verify a GitHub-issued attestation Needs an authenticated gh
--signer-workflow org/repo/.github/workflows/release.yml Pin the signing workflow Stricter than --repo on its own
--predicate-type https://slsa.dev/provenance/v1 Select the attestation type The default for build provenance
--format json Machine-readable verification result Combine with --jq
Exit code 0 verified, 1 not verified The only signal a gate should read

Default posture: pin source, tag and builder; run the verifier before the artifact is unpacked; treat a missing attestation exactly like a failed one.

The mental model

Permalink to "The mental model"

A SLSA provenance attestation is a signed statement produced by the build platform at the moment it produced the artifact. It is an in-toto Statement: a subject naming the artifact and its cryptographic digest, a predicateType identifying the schema, and a predicate describing the build. The predicate splits into two halves that answer different questions. buildDefinition records what was requested — the buildType recipe, the externalParameters naming the source repository, ref and workflow inputs, and the resolvedDependencies that pin the exact source commit. runDetails records what happened — the builder.id that identifies the workflow that ran, plus invocation identifiers and timestamps.

Verification is then a matching exercise, not a scan. You compute the digest of the artifact you hold, and you assert that a valid signature exists over a statement whose subject digest equals that value, whose source repository equals the one you trust, and whose builder identity equals the pipeline you operate. Every one of those three must hold. Checking only the signature tells you a build platform signed something; checking only the source tells you nothing about who compiled it; checking only the digest is no better than a checksum file the attacker also controls.

Anatomy of a SLSA provenance attestation An in-toto Statement holds a subject with the artifact digest, a predicate type, and DSSE signature material on the left, while the predicate on the right splits into a build definition describing what was requested and run details describing who built it. in-toto Statement v1 — one signed provenance attestation subject[0] name: app_linux_amd64 digest.sha256: 9f2c4b6d…e17a predicateType slsa.dev/provenance/v1 DSSE signature + transparency log entry predicate.buildDefinition — what was requested buildType: the recipe the platform executed externalParameters: source repo, ref, workflow inputs resolvedDependencies: the exact source commit SHA predicate.runDetails — who ran it, and when builder.id: the workflow identity the verifier pins metadata: invocationId, startedOn, finishedOn byproducts: logs and intermediate outputs

The signature material matters as much as the payload. Modern provenance is signed with a short-lived certificate issued against the build platform’s OIDC identity and recorded in a public transparency log, which is why verification needs no long-lived public key to be distributed and why a verifier can detect an attestation that was minted out of band. This is the same signing machinery behind Verifying Sigstore Provenance for npm Packages; SLSA provenance simply carries a richer predicate about the build itself.

SLSA build levels in practice

Permalink to "SLSA build levels in practice"

The SLSA specification describes Build levels 1 through 3, and each one is a statement about the build platform rather than about the code. Build L1 means provenance exists and describes the build — useful documentation, trivially forged, because the build steps themselves can write whatever they like into it. Build L2 adds a hosted build platform and a signature, so the provenance is tamper-evident and attributable to a service rather than to a laptop. Build L3 adds isolation and non-falsifiable provenance: the signing key is unavailable to the user-controlled build steps, so a compromised build script cannot claim it built something it did not. The earlier draft “Build L4” was dropped in SLSA v1.0, so treat any tooling that still advertises it as out of date.

SLSA Build levels compared A matrix showing that Build L1 only produces provenance, Build L2 adds a hosted platform and a signature, and Build L3 additionally isolates the build so the provenance cannot be forged. What each SLSA Build level asserts about the platform SLSA Build level Provenance produced Built on a hosted platform Provenance signed Isolated and unforgeable Build L1 scripted build yes no no no Build L2 signed by service yes yes yes no Build L3 hardened runner yes yes yes yes

The practical consequence for a consumer is simple: a level is only meaningful once you pin the builder identity. “This artifact has L3 provenance” is not a check a pipeline can run. “This artifact has provenance signed by the workflow at github.com/acme/app/.github/workflows/release.yml, over commit 9f2c4b6d, and that workflow is one I control” is.

Canonical example: a release job that fails closed

Permalink to "Canonical example: a release job that fails closed"

The job below downloads a release binary and its provenance, installs the verifier at a pinned version, and verifies before anything else touches the file. There is no continue-on-error, no || true, and no fallback download path — those three constructs are how provenance gates quietly become decorative.

# .github/workflows/consume-release.yml
name: consume-verified-release

on:
  workflow_dispatch:

permissions:
  contents: read

jobs:
  verify-and-use:
    runs-on: ubuntu-latest
    env:
      ARTIFACT: app_linux_amd64
      SOURCE_URI: github.com/acme/app
      SOURCE_TAG: v1.4.0
      BUILDER_ID: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0
    steps:
      - name: Download artifact and provenance
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release download "$SOURCE_TAG" --repo acme/app \
            --pattern "$ARTIFACT" --pattern "*.intoto.jsonl"

      - name: Install slsa-verifier
        run: go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-[email protected]

      - name: Verify SLSA provenance
        run: |
          slsa-verifier verify-artifact "$ARTIFACT" \
            --provenance-path "$ARTIFACT.intoto.jsonl" \
            --source-uri "$SOURCE_URI" \
            --source-tag "$SOURCE_TAG" \
            --builder-id "$BUILDER_ID"

      - name: Use the verified artifact
        run: |
          chmod +x "$ARTIFACT"
          ./"$ARTIFACT" --version

GitHub Actions runs run: blocks under bash -e, so a non-zero exit from slsa-verifier terminates the step and the job before the “Use the verified artifact” step is ever reached. That ordering is the whole gate: verification is not a report emitted alongside consumption, it is a precondition of it.

On success the verifier prints something close to this — the exact wording shifts a little between releases, so never parse it:

Verified signature against tlog entry index 108831294 at URL: https://search.sigstore.dev/?logIndex=108831294
Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0" at commit 9f2c4b6d1e7a3c8f0b5a2d4e6f8a1c3b5d7e9f01
Verifying artifact app_linux_amd64: PASSED

PASSED: SLSA verification passed

On failure it prints a reason and exits 1:

FAILED: SLSA verification failed: source used to generate the binary does not match provenance: expected source "github.com/acme/app", got "github.com/attacker/app"
A provenance gate that fails closed Three sequential checks compare the artifact digest, the source repository and the builder identity; any mismatch drops the pipeline into a failed state, while passing all three exits zero and allows the artifact to be used. CI gate: all three must match or the job exits non-zero 1. digest artifact sha256 == subject.digest 2. source --source-uri == github.com/org/repo 3. builder --builder-id == pinned workflow PASSED install, unpack or deploy it mismatch mismatch mismatch FAILED — verifier exits 1, the stage stops the pipeline no unverified fallback, no warn-and-continue path exit 0 digest recorded

Variants

Permalink to "Variants"

gh attestation verify for GitHub-hosted artifacts

Permalink to "gh attestation verify for GitHub-hosted artifacts"

Artifacts built with the actions/attest-build-provenance action have their attestation stored by GitHub rather than shipped beside the file, so the bundle does not need to be downloaded separately:

gh attestation verify ./app_linux_amd64 \
  --repo acme/app \
  --signer-workflow acme/app/.github/workflows/release.yml \
  --predicate-type https://slsa.dev/provenance/v1

A successful run reports the digest it loaded, how many attestations it found, and the signer:

Loaded digest sha256:9f2c4b6d1e7a3c8f0b5a2d4e6f8a1c3b5d7e9f01 for file://app_linux_amd64
Loaded 1 attestation from GitHub API
✓ Verification succeeded!

--repo limits the accepted attestation to one repository; --owner is looser and accepts anything the organisation signed. --signer-workflow is the flag that actually pins the pipeline, and it is the one most deployments forget. Add --format json --jq '.[0].verificationResult.signature.certificate.buildSignerURI' when you want the signer recorded in the build log instead of only on screen.

Container images

Permalink to "Container images"

Images are addressed by digest, never by tag, because a tag can be re-pointed at a different manifest after you verified it:

# slsa-github-generator provenance
slsa-verifier verify-image ghcr.io/acme/app@sha256:9f2c4b6d1e7a3c8f0b5a2d4e6f8a1c3b5d7e9f01 \
  --source-uri github.com/acme/app \
  --builder-id "$BUILDER_ID"

# GitHub-stored attestation for the same image
gh attestation verify oci://ghcr.io/acme/app@sha256:9f2c4b6d1e7a3c8f0b5a2d4e6f8a1c3b5d7e9f01 \
  --repo acme/app

Resolve the tag to a digest once, verify that digest, and then reference the digest for the rest of the pipeline. If a later step pulls ghcr.io/acme/app:1.4.0 again by tag, the verification you just performed covers a different set of bytes than the ones you run.

Staging the bundle for constrained runners

Permalink to "Staging the bundle for constrained runners"

Runners without outbound access to the registry API can be handed the attestation as a file. Fetch it in a step that does have access, then verify from disk:

gh attestation download oci://ghcr.io/acme/app@sha256:9f2c4b6d1e7a3c8f0b5a2d4e6f8a1c3b5d7e9f01 \
  --repo acme/app
gh attestation verify ./app_linux_amd64 --repo acme/app --bundle ./sha256:9f2c4b6d….jsonl

The trust material itself can be exported with gh attestation trusted-root and supplied through --custom-trusted-root, but it expires, so refresh it on a schedule rather than committing it once and forgetting it.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Provenance proves where an artifact was built, not that the source was benign. A repository compromise, a malicious pull request that a reviewer waved through, or a hostile transitive dependency all produce provenance that verifies perfectly. Provenance answers “did this come from the pipeline I trust, over the commit I think it did”; it does not answer “is this code safe”. Pair it with review controls such as Detecting Lockfile Tampering in Pull Requests and with an inventory like Generating CycloneDX SBOMs for Frontend Assets.

  • Omitting --builder-id silently widens the trust set. slsa-verifier will accept provenance from any builder it knows about when the flag is absent. If an attacker can trigger a different trusted workflow in the same repository, the check still passes. Always pin the builder workflow URI including its @refs/tags/... version suffix.

  • Verifying a file you then re-download is a race, not a gate. Verify the exact bytes you are going to execute. Downloading, verifying, and then re-fetching “the same” URL in a later step reintroduces the window the gate was meant to close. Keep the verified file on disk, or carry its digest forward and compare before use.

  • A missing attestation is a failure, not a skip. The common regression is a step written as slsa-verifier verify-artifact ... || echo "no provenance, continuing". Every dependency that has not adopted provenance yet needs an explicit, reviewed allowlist entry with an owner and an expiry date, not a shell fallback that swallows the exit code.

  • The verified digest is only useful if it reaches the browser. When a verified build produces web assets, carry the digest through to the tag that loads it — and remember that a <script> with integrity and no crossorigin will be blocked outright rather than checked:

    <script src="https://cdn.example.com/app.9f2c4b.js"
            integrity="sha384-oqVuAfXRKap7fdgcCY5uykM6+R9GqQ8K/uxy9rx7HNQlGYl1kPzQho1wx4JwY8wC"
            crossorigin="anonymous"></script>

Verification Steps

Permalink to "Verification Steps"

1. Confirm the verifier rejects a tampered artifact

Permalink to "1. Confirm the verifier rejects a tampered artifact"

Corrupt one byte and re-run. The attestation is still validly signed, but its subject digest no longer matches the file:

printf '\x00' >> app_linux_amd64
slsa-verifier verify-artifact app_linux_amd64 \
  --provenance-path app_linux_amd64.intoto.jsonl \
  --source-uri github.com/acme/app; echo "exit: $?"

Expected output:

FAILED: SLSA verification failed: artifact hash does not match provenance subject
exit: 1

2. Confirm the source check is actually enforced

Permalink to "2. Confirm the source check is actually enforced"

Restore the artifact, then verify it against a repository it did not come from:

slsa-verifier verify-artifact app_linux_amd64 \
  --provenance-path app_linux_amd64.intoto.jsonl \
  --source-uri github.com/attacker/app; echo "exit: $?"

Expected output is a source mismatch and exit: 1. If this returns 0, the flag was misspelled and the shell passed it through as a positional argument — re-check the command line.

3. Confirm the pipeline stage fails closed

Permalink to "3. Confirm the pipeline stage fails closed"

Point the workflow at a build with no attestation and watch the job stop before the consuming step runs:

- name: Verify SLSA provenance
  run: |
    set -euo pipefail
    slsa-verifier verify-artifact "$ARTIFACT" \
      --provenance-path "$ARTIFACT.intoto.jsonl" \
      --source-uri "$SOURCE_URI" \
      --builder-id "$BUILDER_ID"

The step must show as failed and every later step as skipped. A run where the verify step is red but the deploy step is green means something upstream is catching the exit code — usually a wrapper script missing set -e, or a composite action that reports success regardless.

4. Confirm the check runs on the artifact you install

Permalink to "4. Confirm the check runs on the artifact you install"

For npm dependencies, npm audit signatures reports registry signature and provenance status for the installed tree, which pairs naturally with the reproducible installs described in npm ci vs pnpm --frozen-lockfile vs yarn --immutable:

npm ci --ignore-scripts
npm audit signatures

Expected output names the count of verified packages and lists any that carry no attestation, which is your allowlist review queue.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
What does a SLSA provenance attestation actually prove?

It proves that a specific build platform produced an artifact with a specific digest, from a named source repository at a named commit, using named build parameters. It is a signed statement about the origin of bytes. It says nothing about whether the source code is correct, safe, or free of a deliberate backdoor committed by someone with write access.

Do I need --builder-id if I already pass --source-uri?

Yes, for anything you care about. Without --builder-id, slsa-verifier accepts provenance from any builder in its trusted set, so an attacker who can run a different trusted workflow in your repository can still satisfy the check. Pinning the builder workflow URI narrows the accepted set to the one pipeline you actually operate.

Can I verify provenance without network access?

Partly. slsa-verifier and gh attestation verify both consult a transparency log and trust root by default. You can stage the attestation bundle ahead of time with gh attestation download and pass it with --bundle, and export the trust material with gh attestation trusted-root for --custom-trusted-root, but the trust material still has to be refreshed from the network periodically.

What is the difference between slsa-verifier and gh attestation verify?

slsa-verifier is purpose-built for SLSA provenance and understands the builder identities of the slsa-github-generator workflows, including its own source and tag checks. gh attestation verify is the general GitHub attestation client: it verifies any attestation GitHub stores for a repository or owner, filtered by predicate type, and is the natural fit for artifacts built with the attest-build-provenance action.

Does SLSA Build L3 mean the dependency is safe to install?

No. Build L3 is a statement about the build platform: the build ran in an isolated, hosted environment and the provenance cannot be forged by the build steps themselves. A malicious commit, a compromised maintainer account or a hostile transitive dependency all produce perfectly valid L3 provenance. Provenance narrows who could have built the artifact, not what it does.

Permalink to "Related"

Related Articles

Verifying Sigstore Provenance for npm Packages
Publishing npm Packages with Provenance
Provenance Verification Workflows Supply Chain Auditing & Depend…