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.
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.
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"
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-idsilently widens the trust set.slsa-verifierwill 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>withintegrityand nocrossoriginwill 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.
Related
Permalink to "Related"- Publishing npm Packages with Provenance — the producing half of this workflow, emitting the attestation your consumers will check
- CI/CD Integrity Gates — the wider pattern of build stages that block a release on an integrity signal
- Verifying Deployed Assets Against a Hash Manifest — extending the same digest check past the build and onto the live origin