Build provenance in practice
What it asserts and why the build must be hardened.
It is 02:00 and a container in your cluster is sending traffic to a host nobody recognises. The image is ghcr.io/acme/api:latest, pulled an hour ago, and the registry insists it is yours. Fine. But who built it? From which commit? On which runner? Without provenance the honest answer is 'we have no idea', and that blind spot is exactly what the SolarWinds attackers used: a backdoor slipped in while the software was being built, then signed and shipped as a genuine release that every customer downstream trusted. A shipping container carries a paper trail: which factory packed it, on what day, from which raw materials. Build provenance is that paper trail for software. It is a signed, machine-checkable record that ties one exact set of bytes, named by digest (a cryptographic fingerprint of the file, never a tag anyone can move), to the source, the builder and the process that made them. With it, 'where did this running thing come from?' has an answer a machine can check. The gap between a five-minute containment and a week of forensic archaeology is usually whether that answer exists.
What a provenance attestation actually is
An attestation is a signed note about a thing. in-toto (an open standard for signed statements about software, from the Latin for 'in full') gives that note a fixed shape: a Statement whose subject names the artifact by digest, and whose predicate carries the actual claim. Provenance is one kind of predicate. For build provenance the predicateType is https://slsa.dev/provenance/v1 and the body follows the SLSA v1.0 schema (SLSA is Supply-chain Levels for Software Artifacts, the industry ladder of build-integrity requirements). That body has two halves. buildDefinition is the order form: the buildType, the externalParameters a user controlled (repository, ref, workflow path), and the resolvedDependencies (source and inputs, each pinned by digest). runDetails is the receipt: the builder that actually ran the job, plus metadata such as the continuous integration (CI) run id. The split is the whole point. Anything under externalParameters is request data, so someone with repository access could influence it, while runDetails.builder.id is stamped by the platform itself and is the field you anchor trust to. in-toto is the envelope; SLSA is the specific form inside it, and knowing which layer owns which field is what makes verification precise. Because the subject is a digest, the claim is welded to those exact bytes. Nobody can peel it off and stick it on a look-alike tag or a rebuilt image.
# After building & pushing, capture the image digest, then attest provenance.# actions/attest-build-provenance signs KEYLESS via Fulcio + Rekor and, with# push-to-registry, attaches the attestation to the image as an OCI referrer.# Grant these at the job level, or the first run fails before it signs anything:permissions:contents: readid-token: write # mint the OIDC token Fulcio checksattestations: write # store the attestation against the repopackages: write # only for push-to-registry against GHCRsteps:# ...build and push the image first, then:- name: Attest build provenanceuses: actions/attest-build-provenance@v2with:subject-name: ghcr.io/acme/apisubject-digest: sha256:9f2c8b1e4d... # the exact bytes you pushedpush-to-registry: true
# Non-GitHub builders: produce a SLSA v1.0 predicate, then attest it with cosign.# --type slsaprovenance1 => predicateType https://slsa.dev/provenance/v1# no --key given => keyless (ephemeral Fulcio cert + Rekor entry)# attest the DIGEST, never a tagcosign attest --yes \--type slsaprovenance1 \--predicate provenance.json \ghcr.io/acme/api@sha256:9f2c8b1e4d...
Using payload from: provenance.jsonGenerating ephemeral keys...Retrieving signed certificate from Fulcio...Successfully verified SCT...tlog entry created with index: 148203771
So how does the signing work when there is no key to guard? cosign, and actions/attest-build-provenance underneath it, sign keyless. The client generates a throwaway keypair, presents an OIDC token (OpenID Connect, the same family of identity token behind 'sign in with Google') proving which workflow is asking, and Fulcio returns a short-lived X.509 certificate, good for roughly ten minutes, binding that throwaway public key to the identity: the workflow URI (its unique address) sits in the certificate's SAN (Subject Alternative Name, the field that says who a certificate is for) and the OIDC issuer sits in a custom extension. The signature and a Rekor transparency-log inclusion proof (Rekor is a public, append-only ledger of signing events) are stored next to the artifact, so there is no long-lived private key to steal or rotate. The Statement itself rides inside a DSSE envelope (Dead Simple Signing Envelope), a JSON object with payloadType application/vnd.in-toto+json, the base64 payload, and a signatures array of {keyid, sig}. One detail is easy to skim past: the signature is computed over a Pre-Authentication Encoding, DSSEv1 <len> payloadType <len> payload, which folds payloadType into the signed bytes, so a payload signed as one media type cannot be replayed as another. That payloadType is application/vnd.in-toto+json for every in-toto attestation, though, so it is not the field that separates provenance from an SBOM or a VEX document. predicateType does that, and it is protected the plain way: it sits inside the payload, and the payload is what gets signed. Fulcio and Rekor each get their own lesson. Here, the field you have to read and pin is the predicate.
Inside the predicate: buildDefinition and runDetails
{"_type": "https://in-toto.io/Statement/v1","subject": [{ "name": "ghcr.io/acme/api","digest": { "sha256": "9f2c8b1e4d..." } }],"predicateType": "https://slsa.dev/provenance/v1","predicate": {"buildDefinition": {"buildType": "https://actions.github.io/buildtypes/workflow/v1","externalParameters": {"workflow": {"repository": "https://github.com/acme/api","ref": "refs/heads/main","path": ".github/workflows/release.yml"}},"internalParameters": {"github": { "event_name": "push", "repository_id": "778..." }},"resolvedDependencies": [{ "uri": "git+https://github.com/acme/api@refs/heads/main","digest": { "gitCommit": "a3f0c7e1b9..." } }]},"runDetails": {"builder": {"id": "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main"},"metadata": {"invocationId": "https://github.com/acme/api/actions/runs/10293847561/attempts/1"}}}}
Read that file top to bottom. buildType is an address naming the schema of everything underneath externalParameters (https://actions.github.io/buildtypes/workflow/v1 for a GitHub Actions build), so a verifier knows how to interpret those fields instead of guessing. externalParameters.workflow records the repository, the ref and the path of the workflow file: the human-readable 'who asked for this build'. resolvedDependencies pins what actually went in, by digest. The source commit shows up as a gitCommit digest, alongside any base images or fetched materials, and that is the part which lets you prove the artifact came from reviewed code rather than an attacker's branch or a swapped dependency. internalParameters holds context the platform set for itself (event name, repository id), which is gold during an incident. Then runDetails. builder.id is the trust anchor: for this buildType it is the fully qualified workflow that produced and signed the provenance, here the caller's own release.yml at its ref, because actions/attest-build-provenance runs as a step inside that same workflow. metadata.invocationId points straight back at the exact CI run for audit. One thing decides the SLSA level, and it is not the schema. It is where the provenance gets generated. A record emitted by the same job that runs your build (attest-build-provenance, roughly Level 2) is weaker than one generated in an isolated job the build cannot touch. Delegate to a generator such as slsa-github-generator and builder.id then names that isolated workflow at a pinned tag, which is Level 3. Identical fields, very different trust.
# cosign download attestation returns the DSSE envelope; decode the base64# payload and read the predicate fields a policy engine would compare.cosign download attestation ghcr.io/acme/api@sha256:9f2c8b1e4d... \| jq -r '.payload | @base64d | fromjson| { subject: .subject[0].digest.sha256,buildType: .predicate.buildDefinition.buildType,source: .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit,builder: .predicate.runDetails.builder.id }'
{"subject": "9f2c8b1e4d...","buildType": "https://actions.github.io/buildtypes/workflow/v1","source": "a3f0c7e1b9...","builder": "https://github.com/acme/api/.github/workflows/release.yml@refs/heads/main"}
Verifying: the identity pin is the real control
Generating provenance changes nothing until something checks it. And on its own, a valid keyless signature proves only that some GitHub Actions workflow signed this image, because every Actions token comes from the same Fulcio-trusted issuer. The signature never says which workflow. The security control is identity pinning: --certificate-identity-regexp fixed to your exact workflow and ref, plus --certificate-oidc-issuer fixed to https://token.actions.githubusercontent.com. cosign then runs three independent checks: the DSSE signature against the Fulcio certificate, the Rekor inclusion proof, and the certificate's identity against your pin. Note what is missing from that list: none of the three reads the predicate body, so cosign never compares builder.id or buildType against a value you supplied. slsa-verifier does check the builder, via --source-uri and --builder-id, for slsa-github-generator output, and if you want a gate that pins predicate fields from any builder, that is a policy engine's job: Kyverno or Sigstore's Policy Controller reading the decoded payload above. Verify by digest, and require the predicate's subject digest to equal the digest you are about to run. If the deployed digest and the attested subject ever disagree, treat the image as unprovenanced and fail closed, because a gate that shrugs and admits when the attestation is missing protects nothing at all. The cryptography itself is offline, signature checks plus Merkle-proof arithmetic (a Merkle proof is the small set of hashes showing an entry really sits in the log), a few milliseconds of work. What needs the network is everything around it: pulling the attestation down from the registry and keeping the Sigstore trust root current. On a connected runner it is still cheap enough to run at every gate: release, admission, and again at deploy.
cosign verify-attestation \--type slsaprovenance1 \--certificate-identity-regexp '^https://github\.com/acme/api/\.github/workflows/release\.yml@refs/heads/main$' \--certificate-oidc-issuer 'https://token.actions.githubusercontent.com' \ghcr.io/acme/api@sha256:9f2c8b1e4d...
Verification for ghcr.io/acme/api@sha256:9f2c8b1e4d... --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 certificatesCertificate subject: https://github.com/acme/api/.github/workflows/release.yml@refs/heads/mainCertificate issuer URL: https://token.actions.githubusercontent.com
In production, what breaks is trust configuration, not cryptography. The classic footgun is an unanchored identity regex: github.com/acme also matches a fork, a pull_request workflow, and a repository called github.com/acme-evil. Anchor it, ^...@refs/heads/main$, and always pin the issuer. Prefer keyless so there is no long-lived signing key sitting around waiting to be stolen; if you must use keys, keep them in a KMS (Key Management Service) or an HSM (Hardware Security Module), never in a CI environment variable. Budget for the transparency-log dependency too. Air-gapped or offline verifiers need the Rekor proof bundled with the artifact and the Sigstore trust root refreshed through TUF (The Update Framework, the mechanism that safely distributes those root keys), and your verifiers should be watching Fulcio and Rekor availability, because a hard dependency on a free public-good instance is an availability risk. A cached trust bundle or a private Sigstore deployment takes that risk back. Provenance is one predicate among several: the same in-toto Statement envelope also carries SBOMs (Software Bill of Materials files), test results and VEX (Vulnerability Exploitability eXchange) documents. The next lesson picks up right there, showing how in-toto attestations compose so a single gate can demand 'built by our workflow AND has an SBOM AND passed scanning' before anything is allowed to run.
Try this
Attest one throwaway image with the workflow above, then run the verify command against its digest and watch the three checks pass. Now break the pin deliberately: change refs/heads/main to refs/heads/anything inside --certificate-identity-regexp and run it again. cosign exits non-zero with a 'no matching attestations' error, and that is the result worth sitting with, because it is the same failure you get from a fork, from a pull_request run, and from an image nobody ever attested. Your gate cannot tell those three apart, so whatever it does on failure it has to be willing to do on all of them.
Takeaway
The trap worth remembering here: verify the digest, never the tag. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.