in-toto attestations & provenance
Signed statements about how an artifact was made.
A wax seal on a letter proves exactly one thing. Someone holding the right signet ring closed this envelope. It tells you nothing about what the letter says, who dictated it, or when it was written. A cryptographic signature on a container image is that wax seal. It proves an identity vouched for these exact bytes, and it stops there.
An attestation staples a notarized page to the seal. The page describes what the artifact is and how it came to be, and it is folded into the sealed envelope so the page and the seal cannot be pulled apart without breaking both. in-toto (Latin for 'as a whole', a framework for supply-chain integrity) defines the shape of that envelope. Provenance is the most useful page you can put inside it: a machine-readable record of how the artifact was built, naming the source, the build system, the parameters, and every input. It is the artifact's birth certificate, signed by whoever delivered it, and glued to the artifact's fingerprint (its content digest, a SHA-256 hash that changes if a single byte changes) so it can never be re-stapled onto something else.
What sits inside the envelope
Open one up and you find three layers nested like a box inside a box inside a box. The outer box is a DSSE envelope (Dead Simple Signing Envelope, a thin wrapper that carries a payload and the signatures over it). The signature does not cover the payload bytes alone. It also covers a short label naming the payload's type, folded in before anything is signed. For in-toto attestations that label is always application/vnd.in-toto+json, so nobody can lift these signed bytes out and pass them off as some other kind of signed object. Which flavor of claim this actually is, a bill of materials or a build record, lives one layer deeper in a field called predicateType that you will meet in a moment, and it sits inside the signed payload too. Sign a bill of materials today and nobody can replay it next week as provenance, because the field that says which one it is got signed along with everything else.
Inside the envelope sits the in-toto Statement. It has three fields you will read again and again. subject names the artifact and pins its digest. predicateType is a URL naming which kind of claim this is. predicate is the claim itself, in whatever shape that type defines. Here is a provenance statement in the SLSA (Supply-chain Levels for Software Artifacts, pronounced 'salsa') version 1 format, trimmed to the load-bearing parts.
{"_type": "https://in-toto.io/Statement/v1","subject": [{"name": "registry.acme.internal/payments-api","digest": { "sha256": "9f2a3c1b7d4e...e71b" }}],"predicateType": "https://slsa.dev/provenance/v1","predicate": {"buildDefinition": {"buildType": "https://gitlab.com/gitlab-org/gitlab-runner","externalParameters": {"source": "git+https://gitlab.acme.internal/acme/payments@refs/heads/main","entryPoint": ".gitlab-ci.yml"},"resolvedDependencies": [{"uri": "git+https://gitlab.acme.internal/acme/payments@refs/heads/main","digest": { "gitCommit": "3f7c1e2b9a..." }}]},"runDetails": {"builder": { "id": "https://gitlab.acme.internal/ci" },"metadata": {"invocationId": "https://gitlab.acme.internal/acme/payments/-/jobs/184423","startedOn": "2026-07-17T09:14:02Z","finishedOn": "2026-07-17T09:17:36Z"}}}}
Read it top down. The subject is payments-api pinned to one sha256 digest, so this whole document is bound to that one image and no other. predicateType says the predicate is SLSA provenance version 1. Inside predicate, buildDefinition.externalParameters.source records the git repository and ref the build started from. resolvedDependencies lists the inputs the builder actually pulled, each pinned by commit or digest, so 'we built main' becomes 'we built commit 3f7c1e2'. runDetails.builder.id is the identity of the machine that ran the build, and metadata timestamps the run and links back to the exact job.
Pull one off a real image and read it
Attestations do not live in a sidecar file you can lose. They live in the OCI (Open Container Initiative, the standard for container images and registries) registry right next to the image, indexed by the image's digest. Pull the image and the evidence is already there to fetch. cosign, the Sigstore signing tool, reads it for you.
$ cosign download attestation "$IMAGE@$DIGEST" | jq 'keys'
["payload","payloadType","signatures"]
$ cosign download attestation "$IMAGE@$DIGEST" \| jq -r '.payload' | base64 -d \| jq '{type: ._type, subject, predicateType}'
{"type": "https://in-toto.io/Statement/v1","subject": [{"name": "registry.acme.internal/payments-api","digest": {"sha256": "9f2a3c1b7d4e...e71b"}}],"predicateType": "https://slsa.dev/provenance/v1"}
Because the attestation is stored under the image's digest, it travels with the image through every registry mirror and cache. Swap the image for a different one and the digest changes, so the old attestation no longer points at it. That binding is the whole game.
Provenance, the birth certificate
This is where a defender earns their pay. A green build is not evidence of anything on its own. Provenance turns 'trust me' into a set of fields you can check against what you expect. Does source name the repository you actually trust, on a branch you actually ship from, or does it name a fork nobody reviewed? Does builder.id match your CI (continuous integration, the automated system that builds and tests every change), or does it say the image was built on a laptop and pushed by hand? Are the resolvedDependencies pinned to commits and digests, or are they floating tags an attacker could quietly move under you?
An attacker who slips a backdoored image into your registry has to answer those questions in the paperwork. Build from a private fork, and the source field gives them away. Build outside the pipeline, and the builder identity is wrong. Hand-edit the JSON (the plain-text format the statement is written in) to make it lie, and the signature over it breaks. Provenance does not stop an attacker from pushing an image. It stops the image from passing a check that reads the provenance.
Beyond provenance, the evidence folder
Provenance is one page. The same envelope carries any signed statement about the same digest, and a mature artifact collects a folder of them. An SBOM (software bill of materials, an ingredient list of every component inside) attested as a signed claim about contents. A vulnerability scan result attested as a signed claim that the artifact passed scanning at a moment in time. A test report. A human review sign-off before release. Each is a structured statement, bound to the same digest, signed by whoever produced it. A consumer then asks narrow questions, was this scanned, what is in it, who approved it, and gets an answer it can verify instead of a dashboard it has to take on faith.
Make one, then verify it
Creating an attestation and checking it is two commands. Start with signing. The file you hand cosign is not the whole statement you read earlier. It holds only the predicate body, the buildDefinition and runDetails. cosign reads the image to fill in the subject, takes the predicateType from the --type slsaprovenance1 flag, assembles the full statement you saw above, wraps it in a DSSE envelope, and signs that. You describe the build. cosign builds the paperwork around it.
$ export IMAGE=registry.acme.internal/payments-api$ export DIGEST=sha256:9f2a3c1b7d4e...e71b$ cosign attest --yes --predicate predicate.json \--type slsaprovenance1 "$IMAGE@$DIGEST"
Generating ephemeral keys...Retrieving signed certificate...tlog entry created with index: 74839201
That was keyless signing, and it runs like the front desk of a locked building. You show the guard your ID, they print a visitor badge good for the next hour, and they write your name in a sign-in book whose pages cannot be torn out. cosign runs that same errand for your build. It asks your CI for an OIDC (OpenID Connect, a standard way for one machine to prove to another that it is who it claims to be) token, hands that token to Fulcio (Sigstore's certificate authority, the badge printer in this story), and gets back a short-lived certificate that ties the signature to your build's identity for about ten minutes. Then it records the whole exchange in Rekor (Sigstore's append-only transparency log, the sign-in book you can read but never edit). No long-lived private key sits on a disk waiting to be stolen.
$ cosign verify-attestation --type slsaprovenance1 \--certificate-identity-regexp "https://gitlab.acme.internal/acme/.*" \--certificate-oidc-issuer https://gitlab.acme.internal \"$IMAGE@$DIGEST" | jq '.payloadType'
Verification for registry.acme.internal/payments-api@sha256:9f2a3c1b7d4e...e71b --The following checks were performed on each of these signatures:- The cosign claims were validated- Existence of the claims in the transparency log was verified offline- The code-signing certificate was verified using trusted certificate authority certificates"application/vnd.in-toto+json"
Verification runs five checks, and every one has to pass. The certificate has to chain to Fulcio's trusted root. The identity in the certificate has to match your regular expression. The OIDC issuer has to be the one you named. An entry for it has to exist in Rekor. And the subject digest has to match the image you are verifying. Loosen any one of them and you have weakened the gate.
Point the same command at an image an attacker pushed, built under a different identity on a public runner, and watch it refuse.
$ cosign verify-attestation --type slsaprovenance1 \--certificate-identity-regexp "https://gitlab.acme.internal/acme/.*" \--certificate-oidc-issuer https://gitlab.acme.internal \registry.acme.internal/payments-api@sha256:c0ffee11bad2...bad1
Error: no matching attestations: none of the expected identities matched what was in the certificatemain.go:74: error during command execution: no matching attestations: none of the expected identities matched what was in the certificate
That single non-zero exit is the difference between a registry full of images and a registry full of images whose origin you can prove.
Let the builder write the certificate
One rule decides whether provenance is worth anything. You do not let travelers stamp their own passports. Border control does, in a booth the traveler cannot reach into. Provenance works the same way. If your build steps generate and sign their own provenance, then a single compromised step, a poisoned dependency, a malicious test script, sits inside the same environment that describes the build, and it can write whatever flattering history it likes and sign it with the build's own identity.
SLSA frames this as build levels. Build Level 1 means provenance exists. Level 2 means it is signed and comes from a hosted build service. Level 3 means the build platform's control plane generates the provenance in isolation from the steps it runs, so the steps cannot forge it. Moving provenance generation out of the job and into the platform is the jump from 'the build says it built the right thing' to 'the platform, which the build cannot tamper with, says so'.
--certificate-identity-regexp ".*" will happily accept an attestation signed by anyone, including an attacker's public-CI identity, and still print a green banner. And the --type shorthands map to specific predicate versions: slsaprovenance is v0.2, slsaprovenance1 is v1. Ask for the wrong one and cosign returns 'no matching attestations', which looks identical to having no attestation at all. Pin the type to what you produce, scope the identity to your own build system, and then confirm that a deliberately wrong identity actually fails.Put the verify call in the place that admits artifacts, pin --type to the exact predicate version you produce, and keep --certificate-identity-regexp as tight as your build identities allow. Generate and sign in the pipeline, verify and refuse at the gate. The next section wires that verify call into an admission controller, so a missing or wrong attestation blocks the deploy instead of printing a warning nobody reads.
Try this
Run cosign download attestation "$IMAGE@$DIGEST" | jq 'keys' on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: a loose identity regexp verifies nothing. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.