Automating SLSA Level 3 Compliance in GitHub Actions Pipelines
Deconstructing the Leap to SLSA Level 3
For senior engineers tasked with securing software delivery pipelines, the term 'SLSA' (Supply-chain Levels for Software Artifacts) has become unavoidable. While SLSA Levels 1 and 2 offer foundational improvements by requiring provenance and version control, they still leave a critical trust gap: the provenance itself can be forged or tampered with by a compromised build script. SLSA Level 3 closes this gap by introducing stringent requirements that shift the trust anchor from the user-controlled script to the build platform itself.
Understanding the nuances of the L2 to L3 transition is paramount for a successful implementation. It's not merely about adding more steps; it's about fundamentally changing who vouches for the build's integrity.
The Core Differentiators of SLSA Level 3
Achieving these requirements manually is complex and error-prone. Fortunately, the ecosystem around GitHub Actions and the Linux Foundation's Sigstore project has matured to a point where automation is not only possible but practical. This article will demonstrate the definitive pattern for achieving this.
The Production Toolchain: GitHub Actions, OIDC, and Sigstore
Our strategy hinges on a synergistic combination of three key technologies:
*   GitHub Actions: Provides the ephemeral build runners and, crucially, a built-in OIDC provider. This provider can issue short-lived JSON Web Tokens (JWTs) that are cryptographically signed by GitHub and contain verifiable claims about the specific workflow run (repository, commit_sha, job_workflow_ref, etc.).
* Sigstore Project: An open-source suite of tools for signing and verifying software artifacts. We will use:
* Fulcio: A root Certificate Authority that issues short-lived code-signing certificates based on OIDC tokens. This is the magic link: it transforms a verifiable statement about a CI/CD job into a cryptographic identity.
* Rekor: A public, immutable transparency log. All signing events are recorded in Rekor, providing an auditable, timestamped history that prevents back-dating attacks and allows for discovery of compromised keys.
* Cosign: A command-line tool that orchestrates the signing and verification process, interacting with Fulcio and Rekor.
* SLSA Generic Generator: A trusted GitHub Action developed by the SLSA framework team. It acts as a wrapper around the build process, observing its inputs and outputs, and using the OIDC-to-Sigstore flow to generate and sign the final SLSA-compliant provenance attestation.
This toolchain creates a verifiable chain of trust: a consumer can verify that an artifact was produced by a specific workflow in a specific repository at a specific commit, with the attestation signed by GitHub's trusted identity—not by a developer's GPG key checked into a secret.
Implementation: A Multi-Stage Pipeline for a Go Application
Let's build a production-grade workflow for a simple Go application. Our goal is to build a binary, generate SLSA L3 provenance for it, and then package both into a GitHub Release. The consumer can then download both and verify the binary's integrity.
Our project structure:
. 
├── .github/
│   └── workflows/
│       └── release.yml
├── go.mod
└── main.gomain.go
package main
import (
	"fmt"
	"runtime"
)
// Injected by the build process
var version = "dev"
var commit = "none"
var date = "unknown"
func main() {
	fmt.Printf("App Version: %s\n", version)
	fmt.Printf("Git Commit:  %s\n", commit)
	fmt.Printf("Build Date:  %s\n", date)
	fmt.Printf("Go Version:  %s\n", runtime.Version())
	fmt.Printf("OS/Arch:     %s/%s\n", runtime.GOOS, runtime.GOARCH)
}The GitHub Actions Workflow: `release.yml`
This workflow is the heart of our implementation. It uses multiple jobs to separate concerns and ensure the provenance generation step is isolated and trusted.
name: SLSA L3 Release Pipeline
on:
  push:
    tags:
      - 'v*'
permissions:
  contents: read # Default to read-only permissions
jobs:
  # Job 1: Build the Go binary
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read # This job only needs to read the source code
    outputs:
      binary-name: ${{ steps.get_name.outputs.name }}
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4
      - name: Set up Go
        uses: actions/setup-go@v5
        with:
          go-version: '1.21'
      - name: Get build metadata
        id: metadata
        run: |
          echo "version=${GITHUB_REF_NAME#v}" >> $GITHUB_OUTPUT
          echo "commit=${GITHUB_SHA}" >> $GITHUB_OUTPUT
          echo "date=$(date -u +'%Y-%m-%dT%H:%M:%SZ')" >> $GITHUB_OUTPUT
      - name: Build Go binary
        id: build
        env:
          CGO_ENABLED: 0
        run: |
          BINARY_NAME="my-app-${{ runner.os }}-${{ runner.arch }}"
          go build -trimpath -ldflags="-s -w -X main.version=${{ steps.metadata.outputs.version }} -X main.commit=${{ steps.metadata.outputs.commit }} -X main.date=${{ steps.metadata.outputs.date }}" -o "${BINARY_NAME}" main.go
          echo "name=${BINARY_NAME}" >> $GITHUB_OUTPUT
      - name: Upload artifact
        uses: actions/upload-artifact@v4
        with:
          name: ${{ steps.build.outputs.name }}
          path: ${{ steps.build.outputs.name }}
          retention-days: 1
  # Job 2: Generate SLSA Provenance
  provenance:
    needs: [build]
    permissions:
      actions: read # To read artifact produced by the build job
      id-token: write # CRITICAL: Required to authenticate to Sigstore's OIDC provider
      contents: write # To upload assets to a release
    uses: slsa-framework/slsa-github-generator/.github/workflows/[email protected]
    with:
      base64-subjects: "${{ base64(needs.build.outputs.binary-name) }}"
      provenance-name: "${{ needs.build.outputs.binary-name }}.provenance.intoto.jsonl"
      upload-assets: true # Upload provenance to the release
  # Job 3: Create Release and Attach Binary
  release:
    needs: [build, provenance]
    runs-on: ubuntu-latest
    permissions:
      contents: write # To create the release and upload the binary
      actions: read # To download the binary artifact
    steps:
      - name: Download binary artifact
        uses: actions/download-artifact@v4
        with:
          name: ${{ needs.build.outputs.binary-name }}
      - name: Create GitHub Release
        env:
          GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
        run: |
          gh release upload ${{ github.ref_name }} ${{ needs.build.outputs.binary-name }} --clobberAnalysis of the Workflow
Let's break down the critical components:
permissions are read-only. The provenance job is explicitly granted id-token: write, which allows it to request a JWT from GitHub's OIDC provider. This is the cornerstone of the entire process.build Job: This is a standard build job. It checks out the code, builds the binary, and uploads it as a workflow artifact. Note the use of outputs to pass the generated binary name to downstream jobs. This avoids hardcoding and makes the workflow more robust.provenance Job: This is where the SLSA L3 magic happens. Instead of running steps directly, we use a reusable workflow: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml. This is a deliberate design choice by the SLSA team. By calling a trusted, third-party workflow, you delegate the provenance generation to a context that your own build scripts cannot influence. This helps fulfill the "non-forgeable" requirement.    *   uses: [email protected]: Pinning to a full-length commit SHA is even better for security, but pinning to a tag is a common and acceptable practice.
    *   base64-subjects: This is how we tell the generator which artifact(s) to create provenance for. It expects a base64-encoded string containing the artifact names and their SHA256 digests. The slsa-github-generator has a simplified input where you can just pass the artifact name, and it will calculate the hash itself.
    *   upload-assets: true: This convenient option tells the generator to automatically attach the generated provenance file to the GitHub Release created for the tag.
release Job: This job's primary responsibility is to attach the compiled binary to the same release. We use the official gh CLI for this. It runs after provenance generation, ensuring the release is only finalized once the attestation is complete.Under the hood, the slsa-github-generator reusable workflow performs these steps:
base64-subjects.- Calculates its SHA256 hash.
https://token.actions.githubusercontent.com.- Sends this JWT to the public Fulcio instance.
https://github.com/my-org/my-repo/.github/workflows/release.yml@refs/tags/v1.0.0) in the Subject Alternative Name (SAN) field.- It generates the SLSA provenance predicate, detailing the builder ID, build steps, and artifact hashes.
- It wraps this in an in-toto attestation.
cosign.- It uploads the signature and certificate details to the Rekor transparency log.
.intoto.jsonl file.Verification: Establishing the Chain of Trust
Generating provenance is only half the battle. The true value lies in the consumer's ability to verify it. A user or an automated deployment system would perform the following steps after downloading the binary (my-app-linux-amd64) and its provenance (my-app-linux-amd64.provenance.intoto.jsonl) from the GitHub release.
First, install the verifier:
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latestNext, run the verification command:
# Example for a release v1.0.0 from repository my-org/my-repo
slsa-verifier verify-artifact \
  --provenance-path my-app-linux-amd64.provenance.intoto.jsonl \
  --source-uri github.com/my-org/my-repo \
  --source-tag v1.0.0 \
  my-app-linux-amd64If successful, the output will be:
Verified. OKWhat is slsa-verifier actually doing?
    *   The OIDC issuer is https://token.actions.githubusercontent.com.
    *   The SAN contains the expected builder workflow path: https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml.
    *   It calculates the SHA256 hash of the local binary file (my-app-linux-amd64) and ensures it matches the subject hash recorded in the provenance.
    *   It checks that the source-uri and source-tag provided by the user match the repository and ref details recorded in the provenance. This prevents a valid provenance from one project from being used to endorse an artifact from another.
If any of these steps fail, the verifier will exit with a non-zero status code and a detailed error message, preventing the artifact from being trusted.
Advanced Edge Cases and Production Considerations
While the above pattern is robust, senior engineers must consider several edge cases in real-world scenarios.
Edge Case 1: Multi-Artifact and Container Builds
What if your build produces multiple binaries (e.g., for different OS/architectures) or a Docker container image?
*   Multiple Binaries: The slsa-github-generator can handle this. You can pass a multi-line string of base64-encoded subjects. The best practice is often to have a matrix build job that produces all binaries, then a single downstream provenance job that attests to all of them at once.
*   Container Images: Attesting to container images is more complex because an image is a collection of layers (a manifest) rather than a single file. The SLSA team provides a separate reusable workflow for this: slsa-github-generator/.github/workflows/generator_container_slsa3.yml. This workflow integrates with tools like Docker BuildKit to capture layer digests and generate a provenance attestation for the final image manifest. The verification process is also slightly different, using cosign verify-attestation against the image tag in the registry.
Edge Case 2: Self-Hosted Runners and Private Repositories
The trust model described relies on the public, GitHub-hosted runners and the public Sigstore instance. This changes with self-hosted runners.
   Trust: When using self-hosted runners, the trust anchor is no longer GitHub's infrastructure, but your* runner management. A compromise of a self-hosted runner could allow an attacker to exfiltrate the OIDC token and sign malicious attestations. While still better than storing long-lived secrets, it weakens the SLSA guarantee. Achieving a high level of trust requires hardening the runners, using ephemeral runners (e.g., via the actions-runner-controller for Kubernetes), and tightly scoping their network access.
* Verification Policy: Consumers of artifacts built on self-hosted runners must adjust their verification policy. They can no longer trust the default GitHub builder identity. Instead, they must configure their verifier to trust a specific builder ID corresponding to the self-hosted runner's workflow.
Edge Case 3: Reproducible Builds as a Complementary Control
SLSA L3 guarantees how an artifact was built, but it does not guarantee that the build is deterministic. Two builds from the same commit could produce bit-different binaries due to timestamps, random build IDs, or non-deterministic compiler behavior. This is where reproducible builds come in.
By making your build process fully deterministic (e.g., using Go's -trimpath flag, zeroing out timestamps in archives), you enable a powerful secondary verification. Anyone can check out the same source code, run the build steps described in the SLSA provenance, and produce a bit-for-bit identical artifact. This provides the ultimate proof that the provenance is not only authentic but also accurate.
Combining SLSA L3 non-forgeable provenance with a reproducible build process creates a formidable defense against supply chain attacks.
Performance and Cost Considerations
Introducing the provenance generation step adds a small overhead to the pipeline. The provenance job typically takes 30-60 seconds to complete, as it involves network calls to Fulcio and Rekor.
* Latency: For most release pipelines, this additional minute is negligible compared to compilation, testing, and deployment times.
* Cost: Since this runs on standard GitHub-hosted runners, the cost is simply the additional runner minutes consumed. For public repositories, this is free. For private repositories, the cost is minimal.
The security benefits of a verifiable, non-forgeable build record far outweigh these minor performance and cost implications.
Conclusion: From Theory to Production Reality
Automating SLSA Level 3 compliance is no longer a theoretical exercise. By leveraging the native capabilities of GitHub Actions—specifically its OIDC provider—in concert with the public infrastructure offered by Sigstore, engineering teams can implement a robust, automated, and auditable system for generating non-forgeable build provenance.
The pattern presented here—separating build and provenance generation into distinct jobs and using the official slsa-github-generator reusable workflow—is the industry-accepted best practice. It establishes a verifiable chain of trust from the source code to the final artifact, allowing consumers to cryptographically confirm that the software they are running is exactly what the original authors intended to build. For senior engineers responsible for the integrity of their software, mastering this pattern is an essential skill in the modern era of supply chain security.