DEV Community

Cover image for SLSA, Sigstore and Provenance — Complete Practical Guide
Kamal Namdeo
Kamal Namdeo

Posted on

SLSA, Sigstore and Provenance — Complete Practical Guide

SLSA + Sigstore + Provenance — Complete Mental Model

1. The problem this whole ecosystem solves

Traditional security scanning checks what's in an artifact (CVEs in dependencies).
It says nothing about how the artifact was built or whether the thing you're
running is actually what the source code says it should be
.

Real attacks this misses:

  • SolarWinds (2020): attacker compromised the build system, not the source repo. Code review would've shown clean code — the binary was tampered with during compilation.
  • xz backdoor (2024): malicious code was injected into the release tarball, not the git repo. git log looked fine. The tarball didn't match the source.
  • event-stream npm (2018): a new maintainer with no history pushed a malicious version. No build-system compromise needed — just trust exploitation.

None of these are "vulnerable dependency" problems. They're supply chain integrity
problems: can you prove a binary came from a specific source, built by a specific
trusted process, without tampering in between?

That's what SLSA (framework/checklist) + Sigstore (tooling) + in-toto (attestation format) exist to solve.

Think of it like Go modules with go.sumgo.sum proves the content hasn't
changed since you first fetched it (hash pinning). SLSA provenance is go.sum's
older cousin: it proves not just "the bytes are what I saw before" but "here is
cryptographic evidence of the exact build process that produced these bytes."


2. SLSA — Supply-chain Levels for Software Artifacts

SLSA (pronounced "salsa") is not a tool. It's a specification/checklist —
like PCI-DSS for supply chains. It defines maturity levels for your build process.

Level Requirement Real-world meaning
L0 Nothing No guarantees
L1 Provenance exists, build is scripted You have a record of how it was built, but it could be forged (e.g., generated by the same machine that could tamper with the build)
L2 Provenance is generated by a hosted build service, signed A trusted third party (GitHub Actions, GitLab CI, Cloud Build) attests to the build, not you. Harder to forge
L3 Build runs in an isolated/hardened environment; provenance is non-forgeable even by build service admins Ephemeral, hermetic build environments; source and dependencies fully tracked

Key artifact SLSA produces: "provenance." This is a signed attestation
(metadata document) that says: *"Artifact X (identified by its SHA256 digest)
was built from source Y (specific commit), using builder Z (specific CI system

  • workflow file), with these exact inputs, at this time."*

Provenance answers: who built it, from what source, using what process, with
what inputs
— and makes tampering with that record detectable.

What provenance actually looks like (in-toto format)

SLSA provenance is encoded using the in-toto attestation framework. Structure:

{
  "_type": "https://in-toto.io/Statement/v1",
  "subject": [
    {
      "name": "ghcr.io/kamal/myapp",
      "digest": { "sha256": "a1b2c3...actual build output hash" }
    }
  ],
  "predicateType": "https://slsa.dev/provenance/v1",
  "predicate": {
    "buildDefinition": {
      "buildType": "https://github.com/actions/workflow@v1",
      "externalParameters": { "workflow": { "ref": "refs/heads/main", "repository": "github.com/kamal/myapp" } },
      "resolvedDependencies": [ { "uri": "git+https://github.com/kamal/myapp", "digest": { "sha1": "commit-hash" } } ]
    },
    "runDetails": {
      "builder": { "id": "https://github.com/actions/runner" },
      "metadata": { "invocationId": "run-12345", "startedOn": "...", "finishedOn": "..." }
    }
  }
}
Enter fullscreen mode Exit fullscreen mode

Three things bolted together:

  1. subject — the digest of what got produced (immutable, content-addressed — same idea as an OCI image digest or Go module checksum)
  2. predicate — the claim being made about the subject (here: SLSA provenance; could also be a vuln scan result, SBOM, test results, etc. — in-toto is a generic envelope)
  3. signature (added by Sigstore, wraps the whole thing) — proof of who's making this claim

This is the critical mental separation: in-toto = format of the claim,
SLSA = what claims you need to make and how strong they must be, Sigstore = how
you sign/verify/timestamp the claim.


3. Sigstore — the signing infrastructure

Sigstore solves: how do you sign artifacts without the nightmare of long-lived
private key management
(rotation, HSMs, leaked keys, org-wide key distribution)?

Three components, each solving one piece:

3a. Fulcio — the Certificate Authority for "keyless signing"

Traditional signing: you generate a keypair, guard the private key forever,
distribute the public key out-of-band. Losing/leaking the key = disaster.

Fulcio flips this: instead of long-lived keys, you get a short-lived
certificate (valid ~10 minutes)
bound to your OIDC identity (your GitHub
login, Google account, or a CI workflow's OIDC token — not a password, an
identity assertion).

Flow:

  1. Your CI job (or you locally) generates an ephemeral keypair in memory.
  2. It authenticates to Fulcio using an OIDC token (e.g., "I am github.com/kamal/myapp running workflow .github/workflows/release.yml on commit abc123" — GitHub's OIDC provider vouches for this).
  3. Fulcio issues a 10-minute X.509 certificate binding that ephemeral public key to that verified identity.
  4. You sign the artifact with the ephemeral private key.
  5. You throw away the private key immediately. There's nothing to leak, nothing to rotate, nothing to steal.

This is the single biggest mental shift from traditional PKI: the identity
is the credential, not a stored secret.
Verification later checks "was this
cert issued to github.com/kamal/myapp's release workflow?" — not "does this
public key match a file I have on disk."

3b. Rekor — the transparency log

Problem: if certs are only valid for 10 minutes, how do you verify a signature
years later after the cert has expired? And how do you stop someone from
secretly minting a cert for your identity?

Rekor is an append-only, tamper-evident transparency log (same design
lineage as Certificate Transparency logs for HTTPS). Every signing event gets
recorded: artifact digest, public key/cert used, timestamp, signature — all
inserted into a Merkle tree.

Why Merkle tree specifically:

  • Every leaf = one log entry (hashed)
  • Every parent node = hash of its two children
  • The root hash changes if any single entry, anywhere in the tree, is altered
  • Rekor publishes signed tree heads (STHs) periodically — anyone can verify the root hash didn't silently change (this is "gossip"/log monitoring)
  • You get an inclusion proof: a small set of sibling hashes (O(log n)) that let you cryptographically prove "entry X is definitely in this tree," without downloading the whole log

This gives you:

  • Non-repudiation: signer can't later claim "I didn't sign that" — it's in a public, immutable log
  • Detectability: if Fulcio (or an attacker) mis-issues a cert for your identity, it shows up in the public log where you (or automated monitors) can catch it
  • Timestamping: proves the signature existed at time T, so it remains valid even after the 10-minute cert expires — this is the trick that makes ephemeral certs workable long-term

3c. Cosign — the CLI that ties it together

cosign is the tool you actually run. It:

  • Talks to Fulcio to get the ephemeral cert (keyless mode) or uses a static keypair (traditional mode)
  • Signs the artifact (typically a container image by digest, or any blob)
  • Pushes the signature to Rekor
  • Stores the signature alongside the image in the OCI registry itself, using the OCI Referrers API / the old sha256-<digest>.sig tag convention — so the signature travels with the image without needing a separate system
  • Also handles attestations (cosign attest) — signing arbitrary in-toto statements (SBOM, SLSA provenance, vuln scan results) and attaching them to the image the same way

3d. cosign sign vs cosign attest — what's actually different

Both commands lean on the exact same Fulcio/Rekor/OIDC machinery under the
hood. What changes is what document actually gets signed.

A useful analogy: sign is putting a wax seal directly on a package. That's
the whole transaction — later, anyone can check "is this seal genuine, and
whose seal is it?" But the seal itself makes no claims beyond "I vouch for
this exact package." attest is writing a signed letter and attaching it to
the package. The letter makes an actual, structured claim — "this was built
from commit abc123 by GitHub Actions run #456"
or "here is the complete
SBOM"
— and it's that letter, not just the package, that gets signed.
Anyone reading it later can verify both "is this letter genuine?" and "what
does it actually say?", and make a decision based on the content.

cosign sign cosign attest
What's signed The bare artifact digest (a minimal "simple signing" payload) A structured in-toto Statement (SBOM, SLSA provenance, vuln scan, custom predicate), wrapped in a DSSE envelope
Claim being made "I vouch that this exact digest is legitimate" "I vouch for this specific, structured claim about this digest"
Verification cosign verify — signed / not signed, by whom cosign verify-attestation --type <predicate> — same identity check, plus the decoded predicate can be fed into a policy engine (Rego/CUE) to reason about content, e.g. "block if SLSA level < 3" or "block if SBOM has a banned license"
Storage in registry Signature object referencing the image manifest DSSE-wrapped attestation, also attached via the same referrer mechanism, tagged separately (.att) from a plain signature (.sig)

One important point that's easy to gloss over: there's no such thing as an
unsigned attestation.
cosign attest doesn't attach a document and then
sign it as a second step — signing is the action. The DSSE envelope exists
specifically to pin down the exact bytes being signed, so the moment you run
attest, you get a signed claim or nothing at all. A predicate document with
no signature is just a JSON file anyone could have written; the signature is
what gives it any evidentiary value.

One-liner to keep: sign proves identity vouched for a digest.
attest proves identity vouched for a signed, structured claim about that
digest.
An image can carry one signature and many attestations (SBOM +
provenance + vuln scan), each independently signed and independently logged
in Rekor.


4. How it all fits together — end to end flow

 Developer pushes code
        │
        ▼
 CI builds artifact (container image)
        │
        ▼
 CI generates SLSA provenance (in-toto statement describing the build)
        │
        ▼
 Cosign requests ephemeral keypair + cert from Fulcio
   (proves identity via CI's OIDC token — no stored secrets)
        │
        ▼
 Cosign signs: (a) the image itself, (b) the provenance attestation, (c) optionally the SBOM
        │
        ▼
 Signatures + cert + timestamp recorded in Rekor (Merkle tree, publicly auditable)
        │
        ▼
 Signature + attestations pushed to registry, attached to image via OCI Referrers API
        │
        ▼
 Consumer (you, a k8s admission controller, etc.) pulls image
        │
        ▼
 cosign verify checks:
   1. Signature is valid for this exact image digest
   2. Cert chains back to Fulcio root, and cert's identity matches an allow-list
      (e.g., "must be signed by github.com/kamal/myapp release workflow")
   3. Rekor has a valid inclusion proof for this signature at a plausible timestamp
        │
        ▼
 Policy engine (e.g., Kyverno, OPA/Gatekeeper) allows or blocks deployment based on
 the SLSA level / provenance content, not just "is it signed"
Enter fullscreen mode Exit fullscreen mode

This is exactly the shape of what you'd want feeding a FedRAMP-grade SBOM
pipeline: SBOM (CycloneDX) as one attestation, SLSA provenance as another,
both signed and logged, both queryable by policy before deploy.


5. Hands-on: do the whole thing locally

Assumes Docker + a registry you can push to (use ghcr.io with a GitHub PAT, or spin up a local registry:2 container). Install cosign first:

# macOS
brew install cosign

# Linux
curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64"
chmod +x cosign-linux-amd64 && sudo mv cosign-linux-amd64 /usr/local/bin/cosign

cosign version
Enter fullscreen mode Exit fullscreen mode

5a. Local registry for practice

docker run -d -p 5000:5000 --name registry registry:2

docker pull alpine:3.19
docker tag alpine:3.19 localhost:5000/myapp:v1
docker push localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

5b. Key-pair signing (offline mode — no Fulcio, simplest to grok first)

cosign generate-key-pair
# produces cosign.key (encrypted private key, you set a password) and cosign.pub

cosign sign --key cosign.key localhost:5000/myapp:v1
# signs by DIGEST, not tag — cosign resolves the tag to a digest first,
# because tags are mutable and digests aren't

cosign verify --key cosign.pub localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

Inspect what actually got pushed — the signature lives as a separate OCI
artifact in the same repo
, referencing your image's digest:

docker exec registry ls /var/lib/registry/docker/registry/v2/repositories/myapp/_manifests/tags
Enter fullscreen mode Exit fullscreen mode

5c. Keyless signing (the real Sigstore flow — needs internet + browser/OIDC)

COSIGN_EXPERIMENTAL=1 cosign sign localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

This pops a browser to authenticate (Google/GitHub/Microsoft OIDC), requests
a short-lived cert from the public Fulcio instance, signs, and pushes the
signature + writes the entry to the public Rekor log. Note there's no
.key file anywhere — nothing to lose, nothing to leak.

Verify, pinning to an identity (this is the important part — verifying who
signed it, not just that it's signed):

COSIGN_EXPERIMENTAL=1 cosign verify \
  --certificate-identity=your-email@example.com \
  --certificate-oidc-issuer=https://accounts.google.com \
  localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

5d. Look at the actual Rekor entry

cosign verify localhost:5000/myapp:v1 --certificate-identity=... --certificate-oidc-issuer=... -o json | jq '.[0].rekorBundle'

# or query Rekor directly by artifact hash
rekor-cli search --sha $(docker inspect --format='{{index .RepoDigests 0}}' localhost:5000/myapp:v1 | cut -d@ -f2)
Enter fullscreen mode Exit fullscreen mode

You'll see the log index, inclusion proof, signed tree head — this is the
Merkle-tree machinery from section 3b made concrete.

5e. Attach a SLSA provenance attestation

cosign attest mirrors cosign sign exactly — it supports the same two
modes. Don't let the flag choice below fool you into thinking attestation
needs a key; it's just the simpler path to demo first.

cat > provenance.json << 'EOF'
{
  "buildType": "https://example.com/local-manual-build",
  "builder": { "id": "kamal-local" },
  "invocation": { "configSource": { "uri": "git+https://github.com/kamal/myapp", "digest": {"sha1": "deadbeef"} } }
}
EOF

# Key-pair mode (reuses the keypair from 5b)
cosign attest --key cosign.key \
  --type slsaprovenance \
  --predicate provenance.json \
  localhost:5000/myapp:v1

cosign verify-attestation --key cosign.pub \
  --type slsaprovenance \
  localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

Keyless equivalent (same Fulcio/OIDC/Rekor flow as 5c — a fresh ephemeral
cert is minted, the attestation gets its own Rekor entry, no key file
involved):

COSIGN_EXPERIMENTAL=1 cosign attest \
  --type slsaprovenance \
  --predicate provenance.json \
  localhost:5000/myapp:v1

COSIGN_EXPERIMENTAL=1 cosign verify-attestation \
  --type slsaprovenance \
  --certificate-identity=your-email@example.com \
  --certificate-oidc-issuer=https://accounts.google.com \
  localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

verify-attestation decodes the in-toto envelope, checks the signature, and
prints the predicate — this is what a policy engine does automatically before
allowing deploy.

5f. Attach your SBOM the same way (ties directly to your day job)

cosign attest --key cosign.key \
  --type cyclonedx \
  --predicate sbom.cyclonedx.json \
  localhost:5000/myapp:v1
Enter fullscreen mode Exit fullscreen mode

Now the image carries three linked, signed claims: the image itself, its
build provenance, and its SBOM — all independently verifiable, all queryable
via the OCI Referrers API without a side database.


6. Where GitHub Actions automates all of this

In real CI (not manual like above), you don't hand-roll provenance JSON — you
use slsa-github-generator, a reusable workflow that:

  • Runs your build in an isolated GitHub-hosted runner
  • Generates the provenance itself (SLSA L3, since GitHub's OIDC + isolated runner makes the provenance non-forgeable by you)
  • Signs via keyless cosign automatically (GitHub's OIDC token is the identity presented to Fulcio — no secrets needed in the workflow at all)
jobs:
  build:
    permissions:
      id-token: write   # <- this is what lets the job mint an OIDC token for Fulcio
      contents: read
    uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
    with:
      image: ghcr.io/kamal/myapp
      digest: ${{ needs.build.outputs.digest }}
Enter fullscreen mode Exit fullscreen mode

permissions: id-token: write is the whole trick — it's GitHub minting a
short-lived OIDC token scoped to this exact job run, which Fulcio trusts
without you ever touching a private key.


7. The mental model to keep forever

  • in-toto = envelope format for "here's a claim about an artifact" (generic — SBOM, provenance, test results all use it)
  • SLSA = the checklist/spec for how trustworthy your build process needs to be, and what the provenance claim must contain
  • Sigstore (Fulcio + Rekor + Cosign) = infrastructure to sign any of these claims without managing long-lived keys, and to make every signature publicly, permanently auditable
  • OCI Referrers API = the plumbing that lets signatures/attestations travel with the artifact in a normal registry, instead of needing a separate metadata database

The Go analogy that'll stick: go.sum pins content (hash of what you fetched).
This whole stack pins provenance — not just "these are the bytes I saw
before" but "here's cryptographic, timestamped, publicly-logged proof of the
exact process, identity, and inputs that produced these bytes."

Top comments (0)