Skip to content
Français
Account: not signed in, sync available My learning
Select theme
Français

in-toto: attestations for the software supply chain

By updated

Improve this page

An image signed with Cosign and shipped with an SBOM proves who published it, but not what happened during the build. in-toto is the framework that answers that question: each pipeline step produces a signed attestation (a signed statement describing what that step did), and a verifier compares these attestations to a policy before accepting the artifact. This lesson is for a DevOps engineer who already knows how to sign an image: it presents the model (layout, link, verification), then its modern form, the SLSA provenance (SLSA, Supply-chain Levels for Software Artifacts, is a framework of build assurance levels), generated in GitHub Actions and verified with slsa-verifier, Cosign or a Kubernetes policy.

Imagine an attacker who compromises your pipeline and silently disables the vulnerability scan step. The image is still signed, the SBOM is still generated, but the scan never ran. Without a trace of what really happened, the tampering is invisible. With a layout that declares the scan step as mandatory, the absence of its attestation makes verification fail. Proving the integrity of every build step is exactly what the DevSecOps SOCLE framework (in French) expects under provenance.

in-toto is a CNCF project with graduated status (the highest CNCF maturity level) since 10 February 2025, adopted notably by GitHub and Google for their attestations.

What you will learn

  • Describe the in-toto model: a layout (the policy), links (the step attestations) and a verification.
  • Explain what a build attestation provides that neither a signature alone nor an SBOM gives.
  • Generate a SLSA provenance, in the in-toto attestation format, with GitHub Actions.
  • Verify an attestation with slsa-verifier and Cosign, then enforce that verification in Kubernetes.
  • Diagnose the most common verification failures.
  • Produce the evidence for SOCLE-REL-PRV-1 (in French), a provenance that describes builder, source and build parameters, and place SOCLE-REL-PRV-2 (in French), the complementary attestations (SBOM, tests, scan).

What are the prerequisites?

You must know how to sign and verify an image with Cosign, and have read the overview of Sigstore, including the short-lived certificates from Fulcio. The SLSA framework (in French) is described in the SOCLE reference; reading it is not required, this lesson restates the essentials. A GitHub account is enough for the examples.

What is in-toto?

in-toto (Latin for "as a whole") is a framework that guarantees the end-to-end integrity of a software production chain.

The idea is simple: each pipeline step (clone, build, test, package) is one unit of work that the policy names and expects. Each step generates an attestation, a signed document that certifies:

  • Which files were present as input (the "materials")
  • Which files were produced as output (the "products")
  • Who ran the step (which key signed)
  • When the step ran

At the end of the pipeline, a verifier compares these attestations to a policy (the "layout") that defines the required steps. If a step is missing, if the hashes do not match, or if a signature is invalid, verification fails. The diagram shows the whole picture: follow the dotted lines from each step to its link, then the solid arrows to the verifier that returns its verdict.

in-toto model: the clone, build and test steps each produce a signed link; the verifier receives the layout signed by the project owner and the three links, then accepts the artifact if signatures, chain and steps are compliant, or rejects it if a check fails.signatures, chain and steps allmatchany check failsLayout(policy signedby the project owner)Step cloneStep buildStep testLink clone(materials, products,signature)Link buildLink testVerifierArtifact acceptedArtifact rejected
The layout is the policy, the links are the proof of execution, the verifier compares them. Source · SVG
Esc to close, + and − to zoom, scroll to move around. 100 %

Why in-toto is needed

A Docker image signature (with Cosign) proves who signed, and the table shows what it leaves unanswered. Each row is a question an auditor can ask, and only a pipeline attestation answers it. Mind the right-hand column: it assumes a layout that declares these steps and attestations produced for each of them. Without a layout, what you prove is limited to what the attestation claims (see the SLSA provenance below).

QuestionSignature aloneWith in-toto and a layout that declares these steps
Who signed the artifact?YesYes
Was the source code scanned?NoYes, if the scan step is declared and attested
Were the tests run?NoYes, if the test step is declared and attested
Does the build come from the right commit?NoYes, if the clone link or the provenance attests it
Was the pipeline bypassed?NoYes, for the steps the layout requires

in-toto fills this gap by providing full traceability of the pipeline, which complements the inventory of an SBOM without replacing it.

The three key concepts

1. Layout (the policy)

The layout sets the rules of the game. It is a JSON document signed by the project owner that specifies:

  • Which steps are required (clone, build, test, sign...)
  • Who may run each step (which keys are authorized). The entities that run steps and sign their attestations are called functionaries: a CI runner, a developer, a build system.
  • Which files must be present as input/output

The example below declares three steps. Read the pubkeys field as "only these functionaries may sign this step", and the MATCH ... FROM rules as "this step's inputs must be exactly what the previous step produced". The empty inspect list means the layout defines no inspection, a check the verifier runs itself on the final artifacts rather than a step executed in the pipeline.

{
"_type": "layout",
"expires": "2027-01-01T00:00:00Z",
"keys": {
"builder-key-id": { "keytype": "ecdsa", "keyval": { "public": "..." } }
},
"steps": [
{
"name": "clone",
"expected_command": ["git", "clone"],
"pubkeys": ["builder-key-id"],
"expected_materials": [],
"expected_products": [["MATCH", "src/*", "WITH", "PRODUCTS", "FROM", "clone"]]
},
{
"name": "build",
"expected_command": ["make", "build"],
"pubkeys": ["builder-key-id"],
"expected_materials": [["MATCH", "src/*", "WITH", "PRODUCTS", "FROM", "clone"]],
"expected_products": [["CREATE", "dist/app"]]
},
{
"name": "test",
"expected_command": ["make", "test"],
"pubkeys": ["builder-key-id"],
"expected_materials": [["MATCH", "dist/app", "WITH", "PRODUCTS", "FROM", "build"]],
"expected_products": []
}
],
"inspect": []
}

The layout is the source of truth. An attacker who modifies the pipeline must also compromise the layout, which requires stealing the project owner's key.

A link (the link metadata of a step) is the attestation generated after a step runs. It contains the SHA-256 hashes of the input and output files. In the example below, materials are the inputs, products the outputs, and byproducts what the command printed or returned along the way:

{
"_type": "link",
"name": "build",
"materials": {
"src/main.go": { "sha256": "a1b2c3..." },
"src/utils.go": { "sha256": "d4e5f6..." }
},
"products": {
"dist/app": { "sha256": "789abc..." }
},
"byproducts": {
"return-value": 0,
"stdout": "Build successful"
},
"command": ["make", "build"],
"environment": {}
}

Each link is signed by the entity that ran the step (the CI runner, a developer, a build system).

3. Verification

At the end of the pipeline, a verifier compares the links to the layout. The three checks below run in this order, and the first one that fails is enough to reject the artifact:

  1. Verify the signatures: each link must be signed by a key authorized in the layout.

  2. Verify the chain: the "products" of step N must match the "materials" of step N+1 (the hashes must be identical).

  3. Verify completeness: every step defined in the layout must have a matching link.

If a single check fails, the artifact is rejected.

SLSA (Supply-chain Levels for Software Artifacts, in French) defines maturity levels for the security of the supply chain. in-toto is the attestation format recommended by SLSA to reach level 2 and above, as the table summarizes.

SLSA levelRequirementin-toto role
Build L1Provenance existsThe provenance is an in-toto attestation
Build L2Signed provenance, generated by a hosted build platformThe attestation is signed (signed envelope)
Build L3Hardened build platform: runs are isolated from one another and the signing key is inaccessible to the build stepsThe format stays the same; isolation comes from the builder (slsa-github-generator, a reusable workflow)

The format does not make a build trustworthy by itself: in-toto describes and signs what happened, while the level reached depends on the platform that produced the attestation. From SLSA provenance to decision shows which provenance reaches which level.

One nuance avoids a confusion: in this GitHub flow, there is no layout file to write. The layout's policy is replaced by the verifier's checks (source repository, tag, builder identity), detailed in the sections below. The layout remains the tool for the general case, when you orchestrate your own steps and their keys.

You must therefore know what this provenance does not prove. The one from slsa-github-generator attests the source repository, the tag or commit and the identity of the builder that produced the artifact. It says nothing about a vulnerability scan or about test execution: to prove those steps, you need a layout that declares them, or dedicated attestations (SBOM, scan result) that are verified in turn.

Generating attestations with GitHub Actions

GitHub Actions can generate SLSA attestations in the in-toto format through the official slsa-github-generator project. This section shows how to implement it for a Docker image. How to read the result, and the admission decision built on it, are covered in From SLSA provenance to decision.

Full workflow

Two separate jobs, and this separation is not cosmetic: the build job never has the id-token: write permission, the provenance job carries it. An attacker who takes over the build step therefore cannot ask GitHub for an OIDC token and sign whatever they want. The only link between the two jobs is the image digest, a value that cannot be forged without changing the image itself. Note also the permissions: {} at workflow level: it removes all default permissions, and each job only asks back for what it needs.

.github/workflows/release-with-provenance.yml
name: Build and attest container
on:
push:
tags: ['v*']
permissions: {}
env:
REGISTRY: ghcr.io
IMAGE_NAME: ${{ github.repository }}
jobs:
build:
runs-on: ubuntu-24.04
outputs:
digest: ${{ steps.push.outputs.digest }}
permissions:
contents: read
packages: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- name: Log in to Container registry
uses: docker/login-action@dbcb813823bdd20940b903addbd779551569679f # v4.6.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push Docker image
id: push
uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
context: .
push: true
tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.ref_name }}
provenance:
needs: build
permissions:
actions: read
id-token: write
packages: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.1.0
with:
image: ghcr.io/${{ github.repository }}
digest: ${{ needs.build.outputs.digest }}
registry-username: ${{ github.actor }}
secrets:
registry-password: ${{ secrets.GITHUB_TOKEN }}

What this workflow does

  1. Build: builds the Docker image and pushes it to the GitHub registry (ghcr.io). The SHA-256 digest is captured as an output.

  2. Provenance: calls the slsa-framework reusable workflow, which generates an in-toto attestation signed by GitHub Actions itself (via OIDC).

  3. Storage: the attestation is attached to the image through OCI signatures and recorded in the Rekor transparency log.

Content of the generated attestation

The generated SLSA attestation is an in-toto document (a Statement) that names the attested subject, here the image by its digest, and carries a predicate, the content of what is claimed: how the image was built. Its type is https://slsa.dev/provenance/v1. In the file stored with the image, this statement is wrapped in a signed envelope (the DSSE format, Dead Simple Signing Envelope): the statement is base64-encoded in a payload field next to the signatures, so that the exact bytes that were signed are the ones you read back. Here is a simplified excerpt of the statement:

{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "ghcr.io/user/myapp",
"digest": { "sha256": "abc123..." }
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/tags/v1.0.0",
"repository": "https://github.com/user/myapp"
}
}
},
"runDetails": {
"builder": {
"id": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0"
},
"metadata": {
"invocationId": "https://github.com/user/myapp/actions/runs/12345"
}
}
}
}

This attestation proves that:

  • The image ghcr.io/user/myapp@sha256:abc123... was built
  • By the specified GitHub Actions workflow
  • From tag v1.0.0 of the user/myapp repository
  • Via the official slsa-github-generator v2.1.0 builder

Verifying an attestation

Generating attestations is pointless if nobody verifies them. Here is how to check that an image was indeed built as expected, with slsa-verifier (repository), then with Cosign. The provenance specification and the in-toto attestation framework detail the fields.

With slsa-verifier

The slsa-verifier tool checks that the attestation matches your requirements:

Terminal window
# Install slsa-verifier (pinned version, not @latest)
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@v2.7.1
# Verify a Docker image
slsa-verifier verify-image ghcr.io/user/myapp@sha256:abc123... \
--source-uri github.com/user/myapp \
--source-tag v1.0.0

Expected result:

Verified signature against tlog entry index 12345678 at URL: https://rekor.sigstore.dev
Verified build using builder "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.1.0"
Verifying artifact ghcr.io/user/myapp@sha256:abc123...
PASSED: SLSA verification passed

With Cosign

Cosign can also verify in-toto attestations. Watch the shortcut passed to --type: slsaprovenance designates the SLSA v0.2 predicate, whereas slsa-github-generator v2 produces https://slsa.dev/provenance/v1. The shortcut to use is therefore slsaprovenance1, otherwise verification rejects an attestation that is nevertheless valid. The command also checks the signer's identity and the OIDC issuer.

Terminal window
# Check that the image has a SLSA v1 attestation
cosign verify-attestation ghcr.io/user/myapp@sha256:abc123... \
--type slsaprovenance1 \
--certificate-identity-regexp="^https://github.com/slsa-framework/slsa-github-generator/" \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com

Extracting and inspecting the attestation

To see the raw content of the attestation, which is the DSSE envelope described above, decode its payload field to read the statement:

Terminal window
# Download the attestation
cosign download attestation ghcr.io/user/myapp@sha256:abc123... > attestation.json
# Decode the payload (base64)
cat attestation.json | jq -r '.payload' | base64 -d | jq .

Automatic verification in Kubernetes

Generating and verifying by hand is fine for tests. In production, you want to automatically block images without a valid attestation.

With Kyverno

Kyverno is a Kubernetes policy engine that can verify attestations before allowing a deployment:

kyverno-slsa-policy.yaml
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: verify-slsa-provenance
spec:
validationActions: [Deny]
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
operations: ["CREATE", "UPDATE"]
resources: ["pods"]
matchImageReferences:
- glob: "ghcr.io/myorg/*"
attestors:
- name: slsaKeyless
cosign:
keyless:
identities:
- issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github.com/slsa-framework/slsa-github-generator/"
attestations:
- name: slsaProvenance
intoto:
type: https://slsa.dev/provenance/v1
validations:
- expression: >-
images.containers.map(image, verifyAttestationSignatures(image,
attestations.slsaProvenance, [attestors.slsaKeyless])).all(e, e > 0)
message: "Image must have a valid SLSA provenance attestation."

The point not to miss is the last section. An ImageValidatingPolicy verifies nothing as long as the validations field does not contain a CEL expression (Common Expression Language, the expression language of Kubernetes) that calls verifyAttestationSignatures. Writing expression: "true" gives a policy that installs without error, declares itself compliant, and lets every image through: the classic trap.

Once the expression is in place, the policy:

  • Applies to all ghcr.io/myorg/* images
  • Requires an attestation whose predicate is https://slsa.dev/provenance/v1
  • Checks that the signature of this attestation really comes from slsa-github-generator, through a GitHub Actions keyless identity

The progressive rollout of this policy, the controlled exceptions and the tracking of denials are covered in the lesson From SLSA provenance to decision. To test it without a cluster, the CLI kyverno apply kyverno-slsa-policy.yaml --resource pod.yaml loads the policy and evaluates the CEL expression. An image referenced by tag alone (ghcr.io/myorg/app:1.0.0) is denied with does not have a digest: the policy therefore requires a reference pinned by digest.

With Sigstore Policy Controller

As an alternative to Kyverno, the Sigstore Policy Controller specializes in verifying signatures and attestations:

clusterimagepolicy.yaml
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: slsa-provenance-required
spec:
images:
- glob: "ghcr.io/myorg/**"
authorities:
- name: slsa-generator
keyless:
url: https://fulcio.sigstore.dev
identities:
- issuerRegExp: "^https://token.actions.githubusercontent.com$"
subjectRegExp: "^https://github.com/slsa-framework/slsa-github-generator/"
attestations:
- name: slsa-provenance
predicateType: https://slsa.dev/provenance/v1

Attestations for binaries (beyond containers)

in-toto is not limited to Docker images. You can attest any artifact: Go binaries, npm packages, tar archives...

Example: Go binary with attestation

The difference with the container workflow lies in what identifies the artifact. An OCI image carries its own digest, the registry computes it. A binary has nothing of the kind: it is up to the build job to produce the SHA-256 hashes and pass them on. That is the role of the Generate hashes step, whose hashes output is base64-encoded because the generator_generic_slsa3.yml reusable workflow expects a list of subjects in that form. If you change the compile line without regenerating the hashes, the attestation will describe binaries that no longer exist and verification will fail.

.github/workflows/go-release-slsa.yml
name: Release Go binary with SLSA
on:
push:
tags: ['v*']
permissions: {}
jobs:
build:
runs-on: ubuntu-24.04
outputs:
hashes: ${{ steps.hash.outputs.hashes }}
permissions:
contents: read
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0
with:
go-version: '1.27'
cache: false
- name: Build binaries
run: |
GOOS=linux GOARCH=amd64 go build -o myapp-linux-amd64 .
GOOS=darwin GOARCH=amd64 go build -o myapp-darwin-amd64 .
GOOS=windows GOARCH=amd64 go build -o myapp-windows-amd64.exe .
- name: Generate hashes
id: hash
run: |
echo "hashes=$(sha256sum myapp-* | base64 -w0)" >> "$GITHUB_OUTPUT"
- uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: binaries
path: myapp-*
provenance:
needs: build
permissions:
actions: read
id-token: write
contents: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_generic_slsa3.yml@v2.1.0
with:
base64-subjects: ${{ needs.build.outputs.hashes }}
upload-assets: true

Verifying a binary

The .intoto.jsonl file published next to the binary contains the attestation in JSON Lines format, one line per attested subject. Unlike the image case, nothing automatically ties this file to the binary: you must pass both to slsa-verifier, which recomputes the hash of the local file and compares it to the one recorded in the attestation. The --source-uri and --source-tag options are not decorative either: without them, an authentic attestation coming from another repository would pass verification.

Terminal window
# Download the binary and its attestation
gh release download v1.0.0 -R user/myapp
# Verify with slsa-verifier
slsa-verifier verify-artifact myapp-linux-amd64 \
--provenance-path myapp-linux-amd64.intoto.jsonl \
--source-uri github.com/user/myapp \
--source-tag v1.0.0

Integration with the Sigstore ecosystem

in-toto integrates tightly with the Sigstore tools for signing and transparency:

ComponentRole in in-toto
CosignSigns attestations, attaches them to OCI images, verifies signatures
FulcioIssues short-lived certificates for keyless signing (no key to manage)
RekorRecords the attestation signature in a public transparency log (non-repudiation)

Keyless signing flow

The flow is the one presented in the Sigstore lesson: the GitHub workflow authenticates with its OIDC token, Fulcio issues it a 10-minute short-lived certificate tied to the workflow identity, the attestation is signed with the associated key, then Rekor timestamps and records it, and the private key is destroyed right away. This flow removes the key management problem: no secret to store, no rotation to schedule.

Troubleshooting

Most verification failures do not signal an attack but a mismatch between what was attested and what is requested: wrong repository URL, generator version that moved between the build and the check, attestation that was never pushed. Read the slsa-verifier error message first: it generally names the field that does not match. One case deserves to be known separately, the last row of the table: an expired Fulcio certificate is normal operation, these certificates only live a few minutes and it is the timestamped Rekor entry that is authoritative.

SymptomLikely causeSolution
FAILED: no matching attestationsAttestation not generated or not attachedCheck the logs of the provenance job
FAILED: source mismatchThe --source-uri does not matchUse the exact repository URL
FAILED: builder mismatchDifferent slsa-github-generator versionCheck the version in the workflow
Kyverno timeoutVerification too slowIncrease webhookConfiguration.timeoutSeconds
certificate has expiredExpired Fulcio certificateNormal, the Rekor proof remains valid

Key points

  1. in-toto = pipeline traceability: it proves not only who signed, but how the artifact was built, within the limit of the steps that the layout declares or that the provenance attests.

  2. Layout + Links: the layout defines the rules, the links attest the execution of each step.

  3. SLSA standard: in-toto is the attestation format recommended by SLSA for Build L2+ levels.

  4. Native GitHub Actions: slsa-github-generator generates compliant attestations without complex configuration.

  5. Mandatory verification: use slsa-verifier, Kyverno or Sigstore Policy Controller to block unattested artifacts.

  6. Keyless signing: via Fulcio and Rekor, no more keys to manage.

  7. Expected evidence (SOCLE-REL-PRV-1): a provenance describing builder, source and build parameters, whose source repository, tag and builder identity slsa-verifier (or the admission policy) confirms; for SOCLE-REL-PRV-2, the complementary attestations (SBOM, tests, scan) linked to the same artifact.

Knowledge check

Check that the essentials of this lesson are in place. The questions only cover what has just been explained here.

Knowledge check

Check what you have learned with this interactive quiz

6 questions
6 min
70% to pass

How it works

  • The timer starts when you click Start
  • Multiple choice, true/false and short answer questions
  • You can move between questions
  • Detailed results are shown at the end

Starts the quiz and the timer

Is this project useful to you?

The guides, courses, quizzes and labs stay free. If you use them regularly, you can contribute to their upkeep and development.

Become a supporting member (new tab) One-time support (new tab) Learn more

Follow the project on LinkedIn