SLSA Build Provenance: Verifying Supply Chain Integrity from Source to Deployment
Problem
A signed artifact tells you that someone you trust signed something. It says nothing about where the source code came from, which builder produced it, whether the build script was modified in transit, or whether a human with access to the signing key replaced the output after the build completed. Signing without provenance is authenticity without accountability.
SLSA (Supply-chain Levels for Software Artifacts, pronounced “salsa”) is a security framework published by Google and adopted by OpenSSF that defines a graduated set of requirements for how artifacts are built and what build metadata must be produced alongside them. The central artifact is the provenance document: a signed, machine-verifiable claim that a specific artifact was produced from a specific source by a specific build process.
The specific gaps SLSA addresses:
- A compromised CI runner can rebuild from modified source, sign the result, and produce a signature that verifies cleanly against the expected identity. A SLSA Build L3 provenance document, generated by an isolated non-forgeable builder, cannot be forged by code running inside the build.
- Source code can be modified after review but before the build triggers. SLSA source requirements chain provenance back to reviewed, versioned commits.
- Third-party build dependencies can be substituted. Provenance captures the resolved dependency tree and the build parameters, making substitutions detectable.
- Deployment verification is impossible without a standard provenance format and tooling to check it. SLSA defines both.
SLSA Levels
SLSA defines four levels (SLSA 1 through 4 in the original spec; the v1.0 spec consolidates these into Build L1–L3 plus separate Source and Process tracks). This article uses the v1.0 Build track terminology.
Build L1: Provenance Exists
Requirements: The build process produces a provenance document and makes it available to consumers. No integrity guarantee on the provenance itself — it can be self-generated by the build script and unsigned.
What it mitigates: Accidental, not adversarial, supply chain problems. Consumers can see where an artifact claims to come from. L1 provenance is sufficient for audit trails and developer transparency dashboards.
What it does not mitigate: A compromised build can generate false L1 provenance claiming the artifact came from a clean source commit. An attacker with write access to the build environment can overwrite the provenance document. L1 provenance is not a security control; it is a starting point.
Build L2: Hosted Build, Signed Provenance
Requirements: The build runs on a hosted build platform (e.g., GitHub Actions hosted runners, Google Cloud Build). The provenance is generated by the build platform — not by user-controlled build scripts — and is signed by the platform’s identity. The platform authenticates itself via a verifiable OIDC token.
What it mitigates: Provenance cannot be self-generated by malicious build scripts; it is produced by the platform after the build completes, independent of what the build script did. An attacker who compromises the build script cannot modify the provenance. Consumers can verify the provenance signature against the platform’s public key or certificate chain.
What it does not mitigate: A build script can still exfiltrate secrets or inject artifacts into the output layer during the build. The build environment itself is multi-tenant and potentially shared across repositories. A sufficiently privileged attacker on the CI platform could influence the provenance before it is signed.
Build L3: Hardened, Isolated Builder
Requirements: The build runs in a hardened build environment with no persistent credentials, no access to the network except through a controlled proxy, and no access to the source repository beyond the declared inputs. The provenance is generated by an isolated service separate from the build itself — it observes the build inputs and outputs but cannot be influenced by the build. The builder is identified by a specific, versioned, pinned reference.
What it mitigates: An attacker with code execution inside the build cannot forge provenance, cannot exfiltrate secrets (no persistent credentials), and cannot substitute artifacts after signing. Provenance accurately reflects what source went in and what binary came out, even if the build script was compromised.
What it does not mitigate: Source-level attacks — if the source repository is compromised before the build triggers, the provenance accurately attests to the compromised source. The SLSA Source track addresses this separately. GitHub Actions supply chain attacks that happen before source checkout are also out of scope for the Build track.
Build L4 (Legacy / SLSA v0.1)
The original SLSA spec defined a fourth level requiring two-person review of all build steps, hermetic builds with no network access during build, and reproducible outputs. The v1.0 spec absorbed most L4 requirements into the Source and Process tracks and the Reproducible Builds track. For practical purposes, SLSA Build L3 is the current highest verifiable level for hosted CI pipelines.
in-toto Attestation Format
SLSA provenance is expressed as an in-toto attestation. Understanding the structure is necessary for writing accurate verification policies.
An in-toto attestation has two layers: the DSSE envelope and the statement.
DSSE Envelope
Dead Simple Signing Envelope (DSSE) is a lightweight container format for signed payloads, standardized at github.com/secure-systems-lab/dsse. The wire format is:
{
"payloadType": "application/vnd.in-toto+json",
"payload": "<base64-encoded statement>",
"signatures": [
{
"keyid": "<optional key identifier>",
"sig": "<base64-encoded signature over PAE(payloadType, payload)>"
}
]
}
The signature is computed over the Pre-Authentication Encoding (PAE) of the payload type and payload bytes, not over the raw JSON. This prevents format-confusion attacks.
in-toto Statement
Inside the DSSE envelope, the decoded payload is an in-toto Statement:
{
"_type": "https://in-toto.io/Statement/v0.1",
"subject": [
{
"name": "ghcr.io/myorg/myapp",
"digest": {
"sha256": "abc123def456..."
}
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": { ... }
}
The subject array is the most critical field. It binds the attestation to specific artifact digests. An attestation without a subject, or with a subject that uses a weak digest algorithm (MD5, SHA-1), cannot be reliably matched to an artifact. The predicateType URI selects the schema for the predicate object.
SLSA Provenance Predicate (v1)
{
"buildDefinition": {
"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/heads/main",
"repository": "https://github.com/myorg/myapp",
"path": ".github/workflows/release.yml"
}
},
"internalParameters": {
"github": {
"event_name": "push",
"repository_id": "123456789",
"repository_owner_id": "987654321"
}
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/myorg/myapp@refs/heads/main",
"digest": {
"gitCommit": "5f3a8b2c1d9e4f7a..."
}
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@refs/tags/v2.0.0"
},
"metadata": {
"invocationId": "https://github.com/myorg/myapp/actions/runs/12345678/attempts/1",
"startedOn": "2026-05-09T10:00:00Z",
"finishedOn": "2026-05-09T10:03:42Z"
}
}
}
The builder.id is the verifiable identifier of the SLSA build system. At verification time, a policy checks this value against an allowlist of known trusted builders. A self-hosted builder or a user-controlled workflow cannot produce a provenance with a trusted builder.id.
Generating SLSA Build L3 Provenance with slsa-github-generator
The slsa-github-generator project provides reusable GitHub Actions workflows that produce SLSA Build L3 provenance. The generator workflow runs as a separate job after the build, receives the artifact digest via the GitHub Actions job output API (not via environment variables that user code can write), and signs the provenance using the GitHub OIDC token.
The key property that makes this L3: the provenance generation happens in a separate job with a separate OIDC token, and the generator code is pinned to a specific released version by digest — user build code cannot inject itself into the provenance generation step.
Generating Provenance for a Binary Artifact
# .github/workflows/release.yml
name: Build and Generate SLSA Provenance
on:
push:
tags:
- 'v*'
permissions:
contents: write
actions: read
id-token: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build binary
run: |
go build -trimpath -o myapp ./cmd/myapp
sha256sum myapp > myapp.sha256
- name: Upload artifact
uses: actions/upload-artifact@6f51ac03b9356f520e9adb1b1b7802705f340c2b # v4.5.0
with:
name: myapp
path: myapp
- name: Generate artifact hash
id: hash
run: |
echo "hashes=$(sha256sum myapp | base64 -w0)" >> "$GITHUB_OUTPUT"
provenance:
needs: [build]
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.0.0
with:
base64-subjects: "${{ needs.build.outputs.hashes }}"
upload-assets: true
The generator_generic_slsa3.yml reusable workflow:
- Fetches the artifact hash from the calling job’s outputs (not from the build environment).
- Requests an OIDC token from GitHub.
- Generates the SLSA provenance document referencing the artifact digest.
- Signs the provenance using Sigstore keyless signing (Fulcio + Rekor).
- Uploads the
.intoto.jsonlprovenance file as a release asset.
The provenance file contains one DSSE-wrapped in-toto statement per artifact subject, newline-delimited (the .jsonl extension).
Generating Provenance for a Container Image
For containers, use the container variant:
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.build.outputs.image }}
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- name: Build and push container image
id: build
uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75 # v6.9.0
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Set image output
run: |
echo "image=ghcr.io/${{ github.repository }}" >> "$GITHUB_OUTPUT"
echo "digest=${{ steps.build.outputs.digest }}" >> "$GITHUB_OUTPUT"
provenance:
needs: [build]
permissions:
actions: read
id-token: write
packages: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
with:
image: ${{ needs.build.outputs.image }}
digest: ${{ needs.build.outputs.digest }}
secrets:
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
The container provenance is stored as an OCI artifact in the registry, co-located with the image, referencing the image digest. It is not a separate file — it is attached to the image manifest via the OCI referrers API.
Verifying Provenance
With slsa-verifier
slsa-verifier is the reference CLI for verifying SLSA provenance documents. It checks the DSSE signature, the Rekor transparency log inclusion, the builder identity, and the subject digest binding.
# Install slsa-verifier
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@v2.6.0
# Verify a binary artifact with its provenance file
slsa-verifier verify-artifact myapp \
--provenance-path myapp.intoto.jsonl \
--source-uri github.com/myorg/myapp \
--source-branch main
# Verify a container image
slsa-verifier verify-image \
ghcr.io/myorg/myapp@sha256:abc123... \
--source-uri github.com/myorg/myapp \
--source-branch main \
--builder-id "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
Successful verification output:
Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0" at level 3
PASSED: Verified SLSA provenance
The --source-uri check ensures the artifact was built from the expected repository, not a fork with a similar name. The --builder-id check pins the exact builder version.
With cosign
For container images, cosign can verify SLSA provenance stored as OCI attestations:
# Verify the SLSA provenance attestation
cosign verify-attestation \
--type slsaprovenance1 \
--certificate-identity-regexp "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@sha256:abc123...
# Extract and inspect the provenance payload
cosign verify-attestation \
--type slsaprovenance1 \
--certificate-identity-regexp "https://github.com/slsa-framework/slsa-github-generator/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@sha256:abc123... \
| jq -r '.payload' | base64 -d | jq '.predicate.buildDefinition'
Pinning the certificate identity to the specific slsa-github-generator workflow path and version prevents an attacker from generating provenance using a user-controlled workflow that happens to share the same issuer.
Enforcing Provenance at Deploy Time
Generating provenance is worthless if nothing checks it before deployment. Enforcement must happen at the point where artifacts enter the execution environment — the Kubernetes admission webhook.
Kyverno Policy
Kyverno can verify SLSA provenance attestations at pod admission time:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-slsa-provenance
spec:
validationFailureAction: Enforce
background: false
webhookTimeoutSeconds: 30
rules:
- name: check-slsa-provenance
match:
any:
- resources:
kinds: [Pod]
namespaces: [production, staging]
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
mutateDigest: true
verifyDigest: true
attestations:
- type: https://slsa.dev/provenance/v1
attestors:
- count: 1
entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
rekor:
url: "https://rekor.sigstore.dev"
conditions:
- all:
- key: "{{ runDetails.builder.id }}"
operator: Equals
value: "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
- key: "{{ buildDefinition.externalParameters.workflow.repository }}"
operator: Equals
value: "https://github.com/myorg/myapp"
- key: "{{ buildDefinition.externalParameters.workflow.ref }}"
operator: Equals
value: "refs/heads/main"
The conditions block enforces that the provenance claims in the predicate match the expected values — not just that a valid signature exists. A validly signed provenance from a fork or a different branch should not be accepted for production deployments.
mutateDigest: true rewrites image references that use mutable tags to the pinned digest before verification — ensuring the verified provenance refers to the same bits that will be pulled.
OPA/Gatekeeper Policy
For environments using OPA with Gatekeeper and the sigstore-policy-controller:
package slsa.provenance
import future.keywords.if
import future.keywords.in
default allow := false
allow if {
provenance := input.predicate
provenance.runDetails.builder.id == "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
provenance.buildDefinition.externalParameters.workflow.repository == "https://github.com/myorg/myapp"
startswith(provenance.buildDefinition.externalParameters.workflow.ref, "refs/heads/main")
}
deny contains msg if {
not allow
msg := sprintf("SLSA provenance does not meet policy requirements: builder=%v", [input.predicate.runDetails.builder.id])
}
This policy runs inside the sigstore Policy Controller admission webhook and evaluates the decoded provenance predicate after signature verification.
Common Mistakes
Unsigned or self-generated provenance. Build scripts that generate their own provenance documents provide no security value. Any build, including a compromised one, can generate a JSON file claiming any source commit and any builder identity. The provenance must be generated by a separate, platform-controlled component and signed by a key the build process cannot access.
Missing or weak subject digests. Provenance that omits the subject field or uses a mutable identifier (an image tag instead of a digest, a filename without a hash) cannot be reliably matched to the artifact it purports to describe. A policy that accepts provenance without checking the subject digest binding is not actually verifying anything. Always use sha256 digests; never use tags or filenames alone.
Trusting builder.id claims without signature verification. The builder.id field in a provenance predicate is a plain string. Anyone can write any string. The security comes from verifying that the DSSE envelope was signed by the certificate whose subject matches the claimed builder identity. Policies that parse the provenance JSON without first verifying the DSSE signature and the Rekor inclusion proof are trusting an unsigned claim.
Permissive builder identity matching. A Kyverno policy that accepts any certificate with issuer https://token.actions.githubusercontent.com accepts provenance generated by any GitHub Actions workflow — including workflows in forks, in other repositories, or using user-controlled builder code. The subject must be pinned to the specific workflow path and ideally to the specific tagged version of the slsa-github-generator workflow.
Signing with an identity that can be obtained by user code. The entire point of SLSA Build L3 is that the provenance signing happens in an isolated context unreachable by user build code. If the signing key or OIDC token is available to the build script — as an environment variable, a mounted secret, or via the instance metadata API — the provenance is Build L2 at best, regardless of what the workflow claims.
No enforcement at deploy time. Generating provenance that is never checked at deployment is security theater. The CI step that generates provenance and the admission policy that enforces it must both exist and be tested. A common failure mode: provenance generation is added to the build pipeline, but the Kyverno or OPA policy is never deployed, or is deployed in Audit mode and the alerts are not acted on.
Pinning the generator action by tag, not by digest. slsa-github-generator/.../generator_generic_slsa3.yml@v2.0.0 is better than @main, but a tag can be force-pushed. Pin by the SHA digest of the referenced commit for complete immutability:
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@sha256:5a775b...
The irony of a supply chain security tool being used without supply chain integrity controls is worth avoiding.
Verification Pipeline
A complete verification workflow, suitable for a pre-deployment script or a GitOps admission gate:
#!/usr/bin/env bash
set -euo pipefail
IMAGE="ghcr.io/myorg/myapp@sha256:${DIGEST}"
SOURCE_URI="github.com/myorg/myapp"
BUILDER_ID="https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
echo "Verifying SLSA provenance for ${IMAGE}"
slsa-verifier verify-image "${IMAGE}" \
--source-uri "${SOURCE_URI}" \
--source-branch main \
--builder-id "${BUILDER_ID}"
echo "Provenance verified. Proceeding with deployment."
Integrate this into a deployment gate — a Tekton task, an Argo CD pre-sync hook, or a GitHub Actions deployment job — so that provenance verification is a hard prerequisite for any deployment, not an optional check. Covert channels introduced after source checkout but before signing are a real risk; see covert channel detection for network-layer controls that complement build integrity controls.