SLSA Level 3 on GitHub Actions: A Practical Guide to Supply Chain Security That Actually Works

SolarWinds. XZ Utils. The Codecov breach. The 3CX supply chain attack. Every one of those incidents had the same root problem: nobody could verify what built the software or where it came from. The binary you shipped looked fine. The build logs were gone or unverifiable. By the time anyone noticed, the damage was done.

SLSA (pronounced "salsa") is the framework the industry landed on to fix exactly this. It stands for Supply-chain Levels for Software Artifacts, and it gives you a concrete, verifiable ladder to climb — from "we have a build script" to "cryptographically proven, tamper-resistant provenance attached to every artifact." Level 3 is where things get serious, and GitHub Actions makes it achievable without standing up your own PKI.

This guide covers the full path from zero to SLSA Build Level 3, with working workflow files. No vague hand-waving. Official repo: slsa-framework/slsa-github-generator.


What SLSA Actually Measures (and What It Doesn’t)

SLSA v1.0 focuses on one thing: the build process. It defines a "Build Track" with three levels:

L1 — Provenance exists. The build system produced a signed document saying "I built this artifact from this source." Someone has to generate it; nothing verifies the build environment is trustworthy.

L2 — Provenance is hosted and signed by the build system itself. The CI platform (not your build script) signs the provenance. Forgery requires compromising the CI platform.

L3 — The build platform is hardened. Builds run in ephemeral, isolated environments. The provenance is generated in a separate, isolated process that the build itself cannot tamper with. On GitHub Actions, this means reusable workflows running on GitHub-hosted runners.

SLSA does not tell you your dependencies are clean, your code has no CVEs, or your secrets are managed well. It’s specifically about the integrity of the build pipeline. Pair it with Dependabot, SBOMs (CycloneDX/SPDX), and secret scanning for a complete picture.


The Architecture of SLSA L3 on GitHub Actions

Before touching a single YAML file, understand why L3 requires reusable workflows.

The isolation guarantee at L3 requires that the thing generating provenance is separate from the thing doing the build — and that the platform can cryptographically attest to both. GitHub achieves this by issuing OIDC tokens to reusable workflows that encode the calling workflow’s identity in the token claims. The slsa-github-generator project (maintained by Google and the OpenSSF) defines reusable workflows that:

  1. Receive your build artifacts (or build your code themselves, depending on the generator type)
  2. Generate SLSA provenance in the standard format
  3. Sign the provenance using Sigstore’s Fulcio CA with a short-lived certificate tied to GitHub’s OIDC token
  4. Upload the provenance to GitHub releases or as a workflow artifact

The OIDC token contains the exact workflow ref, the repo, the commit SHA, and the reusable workflow reference. Sigstore’s transparency log (Rekor) records the signing event permanently. This chain is what makes L3 verifiable by a third party long after the build completes.


Prerequisites

  • A GitHub repository (public or private — both work, but public repos get free Sigstore timestamps via Rekor’s public instance)
  • Releases triggered by pushed tags (v*)
  • Artifacts produced by your existing build step

That’s it. You don’t need to manage certificates, run your own Fulcio instance, or touch cosign directly.


Step 1: SLSA L1 — Get Provenance Existing at All

Start with the generic generator. It works for any artifact type: binaries, tarballs, JARs, Docker images, whatever.

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      # Pass the artifact hash to the provenance generator
      hashes: ${{ steps.hash.outputs.hashes }}
    steps:
      - uses: actions/checkout@v4

      - name: Build artifacts
        run: |
          # Replace this with your actual build command
          make build
          # Outputs: ./dist/myapp-linux-amd64, ./dist/myapp-darwin-amd64

      - name: Generate artifact hashes
        id: hash
        run: |
          cd dist
          # SHA-256 hashes, base64-encoded — required format for the generator
          echo "hashes=$(sha256sum * | base64 -w0)" >> "$GITHUB_OUTPUT"

      - name: Upload artifacts
        uses: actions/upload-artifact@v4
        with:
          name: artifacts
          path: ./dist/
          if-no-files-found: error
          retention-days: 5

This alone gets you L1 only if you wire up the provenance generator in the next step. Without it, this is just a normal build — no SLSA claims.


Step 2: SLSA L2/L3 — The Provenance Generator Workflow

This is where GitHub Actions earns its keep. Add a second job that calls the reusable generator workflow:

  # Continues in the same release.yml file
  provenance:
    needs: [build]
    permissions:
      actions: read       # Required to read workflow run info
      id-token: write     # Required to mint OIDC token for Sigstore
      contents: write     # Required to upload provenance to the release
    # CRITICAL: This MUST be a reusable workflow call for L3.
    # Pin to a specific tag, never @main or @latest.
    uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
    with:
      base64-subjects: "${{ needs.build.outputs.hashes }}"
      # Upload provenance to the GitHub release automatically
      upload-assets: true

That’s the entire provenance job. The reusable workflow does everything else: mints the OIDC token, calls Fulcio for a signing certificate, generates the SLSA provenance JSON, signs it, logs to Rekor, and uploads it.

Why pin to a tag and not a SHA? The generator’s own documentation recommends tag pinning here specifically because the reusable workflow reference is part of the OIDC token claims. Pinning to a commit SHA instead of a tag technically works but makes the provenance harder to read and verify manually. Pin to a signed release tag (v2.0.0) and verify the generator’s own provenance if you’re paranoid — yes, the generator generates its own SLSA provenance.


Step 3: Language-Specific Generators (Go, Container Images)

The generic generator works universally but requires you to build first. For Go projects and container images, dedicated generators exist that build and generate provenance in a single isolated step — this is actually more L3-compliant because the build happens inside the trusted, isolated environment.

Go Binaries

# .github/workflows/release.yml
name: Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    permissions:
      id-token: write
      contents: write
      actions: read
    uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
    with:
      go-version: '1.22'
      # Evaluated-builder config: tells the builder what to compile
      evaluated-envs: "VERSION:${{github.ref_name}}"

You also need a .slsa-goreleaser.yml at the repo root:

# .slsa-goreleaser.yml
version: 2

env:
  - CGO_ENABLED=0
  - GOPROXY=off   # Hermetic: no network access during build

goos:
  - linux
  - darwin
  - windows

goarch:
  - amd64
  - arm64

flags:
  - -trimpath
  - -mod=vendor   # Vendor your deps for hermetic builds

ldflags:
  - "-s -w -X main.Version={{ .Env.VERSION }}"

binary: myapp

The GOPROXY=off and -mod=vendor combination is what makes this truly hermetic — the build cannot reach out to the internet to fetch additional code. This matters for L3’s tamper-resistance claim.

Container Images

name: Container Release

on:
  push:
    tags:
      - 'v*'

jobs:
  build:
    runs-on: ubuntu-latest
    outputs:
      image: ${{ steps.build.outputs.image }}
      digest: ${{ steps.build.outputs.digest }}
    steps:
      - uses: actions/checkout@v4

      - name: Build and push image
        id: build
        uses: docker/build-push-action@v5
        with:
          push: true
          tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
        # Capture the digest — provenance is tied to the digest, not the tag
      
      - name: Set outputs
        id: set-outputs
        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  # Push provenance to GHCR alongside the image
    uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
    with:
      image: ${{ needs.build.outputs.image }}
      digest: ${{ needs.build.outputs.digest }}
      registry-username: ${{ github.actor }}
    secrets:
      registry-password: ${{ secrets.GITHUB_TOKEN }}

Container provenance is attached to the image by digest and stored in the same registry alongside the image layers.


Step 4: Verifying Provenance

Provenance that nobody verifies is just a file. The slsa-verifier tool handles the full verification chain.

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

# Verify a binary artifact
slsa-verifier verify-artifact myapp-linux-amd64 \
  --provenance-path myapp-linux-amd64.intoto.jsonl \
  --source-uri github.com/yourorg/yourrepo \
  --source-tag v1.2.3

# Verify a container image (by digest, not tag)
slsa-verifier verify-image \
  ghcr.io/yourorg/yourapp@sha256:abc123... \
  --source-uri github.com/yourorg/yourrepo \
  --source-tag v1.2.3

Successful output looks like:

Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/builder_go_slsa3.yml@refs/tags/v2.0.0" at SLSA Level 3
PASSED: SLSA verification passed

Integrate this into your deployment pipeline. Before pulling an image into production, run the verifier. Before executing a binary, check it. This is where SLSA pays off — automated verification at the consumption point, not just at the build point.


Gotchas

Self-hosted runners break L3. This is the most common mistake. L3 requires the build to run on a platform-managed, ephemeral environment. Self-hosted runners can be persistent, shared, or compromised. GitHub’s own documentation states self-hosted runners cannot achieve L3. If your organization mandates self-hosted runners for network access reasons, you’re capped at L2.

Tag mutability is your enemy. SLSA provenance is tied to a specific commit SHA. If you push a new commit to a tag (git push --force origin v1.2.3), the provenance still refers to the original SHA and verification will fail. Use immutable tags. Always.

The permissions block is not optional. If you omit id-token: write, the OIDC token is never minted, Sigstore never gets called, and the workflow fails with a cryptic error about token permissions. Every job in the chain that touches provenance needs this permission declared explicitly.

Artifact retention destroys reproducibility. GitHub deletes workflow artifacts after 90 days by default. If your provenance is only stored as a workflow artifact (not uploaded to a release), it disappears. Always use upload-assets: true or push provenance to a registry. For containers, the provenance is attached to the image in the registry — as long as the image survives, the provenance survives.

Vendoring isn’t optional for hermetic builds. If your Go build calls out to the module proxy during the build, you have a network dependency that could change. Pin GOPROXY=off and commit your vendor directory. Same principle applies to other languages: lock files must be committed, and builds should not install dependencies during the build step.

Private repos and Rekor. Sigstore’s public Rekor instance is a public, append-only transparency log. Every signing event is logged there including the repository name and workflow path. For private repositories, this leaks metadata. The slsa-github-generator supports pointing at a private Rekor instance, but that’s a significant operational investment. Evaluate your threat model: usually the metadata leak (repo name, workflow name, tag) is acceptable even for closed-source projects.


Production-Ready Additions

Pin everything, verify the pins. The slsa-github-generator workflows themselves are SLSA L3 artifacts. Download the generator’s own provenance and verify it before trusting the generator. This is the bootstrap problem every supply chain tool faces — the generator pins its own release hashes in its README.

Add SBOM generation alongside provenance. SLSA provenance tells you how the artifact was built. An SBOM tells you what’s inside it. Use anchore/sbom-action or cyclonedx/gh-action in your build job. Store the SBOM as a release asset or attach it to your container image via oras.

      - name: Generate SBOM
        uses: anchore/sbom-action@v0
        with:
          artifact-name: sbom.spdx.json
          output-file: ./sbom.spdx.json
          format: spdx-json

Enforce verification in deployment. Provenance generation is useless without verification gates. Add a verification step to your ArgoCD pre-sync hook, your Helm chart deployment script, or your Kubernetes admission controller. Kyverno and OPA Gatekeeper both have examples for enforcing SLSA verification before allowing workloads to run.

Use GitHub’s built-in artifact attestations for Actions artifacts. As of 2024, GitHub natively supports artifact attestations via actions/attest-build-provenance. This is simpler than the full slsa-github-generator approach and is fine for L1/L2. Use slsa-github-generator when you need L3 and the full SLSA provenance format with third-party verifiability.

      - name: Attest artifacts (GitHub-native, L2)
        uses: actions/attest-build-provenance@v1
        with:
          subject-path: './dist/*'

Sign your git tags. SLSA provenance ties to a commit SHA, but users typically reference tags. If someone can push a new commit to your release tag, provenance verification fails in a confusing way. GPG-sign your release tags and enable tag protection rules on GitHub. Better: use the GitHub environment protection rules to require review before any release workflow runs.


What You’ve Actually Achieved

Once your pipeline is wired up, every release comes with:

  1. A signed SLSA provenance document in the in-toto format, tied to the exact commit SHA
  2. A Rekor entry proving the signing happened at a specific timestamp with a short-lived Sigstore certificate
  3. Cryptographic proof that the build ran on GitHub’s hosted infrastructure, in a reusable workflow with a pinned reference

Anyone — your users, your customers, a security auditor — can download your artifact and run slsa-verifier to confirm it came from your repo, from that specific commit, built by the claimed builder. No trust required beyond GitHub’s OIDC infrastructure and Sigstore’s transparency log.

That’s the actual value proposition: shifting the trust from "we promise we built it correctly" to "here’s cryptographic proof that we did." After XZ Utils and everything that followed, that’s not a nice-to-have. It’s due diligence.

Start with the generic generator on your most critical release artifacts. Get L3 working there first. Then expand to your container images. The whole migration for a typical project takes an afternoon, and your users get provenance they can actually verify.

Leave a comment

👁 Views: 2,289 · Unique visitors: 1,646