Generating attestations is only worth something when they are verified. This guide explains how to validate the provenance of your artefacts before deploying them.
What you will learn
- Verify an artefact with
gh attestation verify - Block an unverified deployment in a CI/CD workflow
- Use cosign and slsa-verifier for SLSA verification
- Show the SLSA level reached through a README badge
- Enforce verification at admission time in Kubernetes
Why verify?
Verifying an attestation means making sure an artefact really is the one you think. An attestation proves:
- The artefact comes from the right repository
- It was built by the right workflow
- The source code matches the expected commit
- The signature is valid (nothing was altered)
Without verification, an attacker could substitute a malicious artefact with nothing raising a flag.
With the GitHub CLI
The GitHub CLI (gh) is the most direct way to verify an attestation: it
queries the GitHub API and validates the Sigstore signature.
Installation
Install the CLI for your system:
# macOSbrew install gh
# Linux (Debian/Ubuntu)curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg | sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpgecho "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] https://cli.github.com/packages stable main" | sudo tee /etc/apt/sources.list.d/github-cli.listsudo apt update && sudo apt install ghVerifying a local file
Point the command at the downloaded file and name the expected repository:
# Verify a downloaded artefactgh attestation verify my-app-v1.0.0.tar.gz --owner owner --repo repoThe output on success:
Loaded digest sha256:abc123... for file my-app-v1.0.0.tar.gzLoaded 1 attestation from GitHub API✓ Verification succeeded!
SHA256 digest: abc123...Sigstore Bundle: presentCertificate Subject: https://github.com/owner/repo/.github/workflows/release.yml@refs/tags/v1.0.0Certificate Issuer: https://token.actions.githubusercontent.comVerifying a Docker image
For an image, prefix the reference with oci://:
# Verify an image from a registrygh attestation verify oci://ghcr.io/owner/app:v1.0.0 --owner ownerVerification options
Several options tighten the verification: an exact digest, the signing workflow, machine-readable output:
# Verify against a specific digestgh attestation verify --digest sha256:abc123... --owner owner --repo repo
# Verify that the source workflow is the right onegh attestation verify my-app.tar.gz \ --owner owner \ --repo repo \ --signer-workflow release.yml
# JSON format for automated processinggh attestation verify my-app.tar.gz --owner owner --format jsonIn a CI/CD workflow
Verification earns its keep when automated: a deployment must never consume an artefact whose provenance has not been validated.
Verifying before deployment
This workflow downloads an artefact, verifies its attestation, and deploys only if the verification succeeds.
name: Deploy
on: workflow_dispatch: inputs: version: description: 'Version to deploy' required: true
permissions: {}
jobs: deploy: runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Download the artefact env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} run: | gh release download "$VERSION" \ --repo "$GITHUB_REPOSITORY" \ --pattern "my-app-*.tar.gz"
- name: Verify the attestation env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | gh attestation verify my-app-*.tar.gz \ --owner "$GITHUB_REPOSITORY_OWNER" \ --repo "${GITHUB_REPOSITORY#*/}"
- name: Deploy (only if the verification succeeded) run: ./deploy.sh my-app-*.tar.gzVerifying an image before pulling it
Same principle for an image: the attestation is validated before the
docker pull.
name: Deploy Image
on: workflow_dispatch: inputs: version: description: 'Version to deploy' required: true
permissions: {}
jobs: deploy: runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Verify the image attestation env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ inputs.version }} run: | gh attestation verify \ "oci://ghcr.io/$GITHUB_REPOSITORY:$VERSION" \ --owner "$GITHUB_REPOSITORY_OWNER"
- name: Pull and deploy env: VERSION: ${{ inputs.version }} run: | docker pull "ghcr.io/$GITHUB_REPOSITORY:$VERSION" kubectl set image deployment/app "app=ghcr.io/$GITHUB_REPOSITORY:$VERSION"With cosign
cosign is the Sigstore signing tool, used behind the scenes by GitHub attestations.
Installation
Install the cosign binary:
# macOSbrew install cosign
# Linuxcurl -LO https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64sudo install cosign-linux-amd64 /usr/local/bin/cosignVerifying an image
cosign verify checks the keyless signature against the identity of the
expected workflow:
# Verify with the Sigstore certificatescosign verify \ --certificate-identity-regexp="https://github.com/owner/repo/.github/workflows/*" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ ghcr.io/owner/app:v1.0.0Verifying the SLSA attestation
cosign verify-attestation additionally validates the SLSA provenance
predicate:
# Download and verify the attestationcosign verify-attestation \ --type slsaprovenance \ --certificate-identity-regexp="https://github.com/owner/repo/.github/workflows/*" \ --certificate-oidc-issuer="https://token.actions.githubusercontent.com" \ ghcr.io/owner/app:v1.0.0With slsa-verifier
slsa-verifier is the official SLSA tool for verifying provenance attestations.
Installation
Install it through Go or by downloading the binary:
# Go installgo install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@latest
# Or download the binarycurl -LO https://github.com/slsa-framework/slsa-verifier/releases/latest/download/slsa-verifier-linux-amd64chmod +x slsa-verifier-linux-amd64sudo mv slsa-verifier-linux-amd64 /usr/local/bin/slsa-verifierVerifying a local artefact
Download the artefact and its provenance file, then run the verification:
# Download the artefact and its attestation from a releasegh release download v1.0.0 --repo owner/repo --pattern "*.tar.gz"gh release download v1.0.0 --repo owner/repo --pattern "*.intoto.jsonl"
# Verify with slsa-verifierslsa-verifier verify-artifact my-app-v1.0.0.tar.gz \ --provenance-path my-app-v1.0.0.intoto.jsonl \ --source-uri github.com/owner/repo \ --source-tag v1.0.0The output on success:
Verified signature against tlog entry index 12345 at URL https://rekor.sigstore.devVerified build using builder "https://github.com/actions/runner" at commit abc123def456SLSA verification passed✓ Verification succeeded!Verifying a container image
For an image, prefer verification by digest rather than by tag:
# Verify a GHCR image by tagslsa-verifier verify-image ghcr.io/owner/app:v1.0.0 \ --source-uri github.com/owner/repo \ --source-tag v1.0.0
# Verify by digest (safer)slsa-verifier verify-image ghcr.io/owner/app@sha256:abc123... \ --source-uri github.com/owner/repo \ --source-tag v1.0.0Showing the SLSA level reached
The --print-provenance option prints the full predicate, including the
buildType that determines the SLSA level:
# Verify and print the SLSA level in verbose modeslsa-verifier verify-image ghcr.io/owner/app:v1.0.0 \ --source-uri github.com/owner/repo \ --source-tag v1.0.0 \ --print-provenanceThe detailed output:
{ "_type": "https://in-toto.io/Statement/v1", "subject": [ { "name": "ghcr.io/owner/app", "digest": {"sha256": "abc123..."} } ], "predicateType": "https://slsa.dev/provenance/v1", "predicate": { "buildDefinition": { "buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1" } }}The SLSA level:
| buildType | SLSA level |
|---|---|
workflow/v1 | SLSA L3 |
workflow/v0 | SLSA L2 |
A complete verification script
This script chains the verification and the display of the SLSA level, and fails cleanly when something is wrong:
#!/bin/bash# verify-slsa.sh - Verify and print the SLSA level
set -e
IMAGE="$1"SOURCE_URI="$2"SOURCE_TAG="$3"
if [ -z "$IMAGE" ] || [ -z "$SOURCE_URI" ] || [ -z "$SOURCE_TAG" ]; then echo "Usage: $0 <image> <source-uri> <source-tag>" echo "Example: $0 ghcr.io/owner/app:v1.0.0 github.com/owner/repo v1.0.0" exit 1fi
echo "SLSA verification of $IMAGE"echo ""
# Verify with slsa-verifierif slsa-verifier verify-image "$IMAGE" \ --source-uri "$SOURCE_URI" \ --source-tag "$SOURCE_TAG" \ --print-provenance > /tmp/slsa-provenance.json 2>&1; then
echo "SLSA verification succeeded" echo ""
# Extract the SLSA level BUILD_TYPE=$(jq -r '.predicate.buildDefinition.buildType' /tmp/slsa-provenance.json)
echo "SLSA level reached:" if [[ "$BUILD_TYPE" == *"workflow/v1"* ]]; then echo " SLSA Level 3" elif [[ "$BUILD_TYPE" == *"workflow/v0"* ]]; then echo " SLSA Level 2" else echo " Unknown level: $BUILD_TYPE" fi
echo "" echo "Provenance details:" jq '{repository: .predicate.buildDefinition.externalParameters.workflow.repository, ref: .predicate.buildDefinition.externalParameters.workflow.ref, builder: .predicate.runDetails.builder.id}' /tmp/slsa-provenance.json
else echo "SLSA verification failed" exit 1fiUsage:
chmod +x verify-slsa.sh./verify-slsa.sh ghcr.io/stephrobert/test-sigstore:v1.0.2 github.com/stephrobert/test-sigstore v1.0.2Showing the SLSA badge in the README
To communicate the SLSA level reached, add a badge to your README.md.
A static badge
The static badge reflects the level you target, to be updated as the project progresses:
<!-- SLSA Level 3 badge -->[](https://slsa.dev)
<!-- SLSA Level 2 badge -->[](https://slsa.dev)
<!-- SLSA Level 1 badge -->[](https://slsa.dev)Rendered:
A dynamic badge with OpenSSF Scorecard
This badge updates automatically with the repository's real score:
[](https://scorecard.dev/viewer/?uri=github.com/owner/repo)It shows the overall score (which includes SLSA among other criteria).
A continuous verification badge
If you have an automatic verification workflow, its status badge proves the verification really runs:
[](https://github.com/owner/repo/actions/workflows/verify-slsa.yml)A complete README example
Here is how those badges and the security section fit together in a README:
# My Application
[](https://slsa.dev)[](https://scorecard.dev/viewer/?uri=github.com/owner/repo)[](https://github.com/owner/repo/actions/workflows/release.yml)
## Supply Chain Security
This project reaches **SLSA Level 3** to guarantee the traceability of the supply chain:
- SLSA provenance generated for every release- Sigstore signatures on every image- Attestations verifiable with `gh attestation verify`
### Verifying a release
```bash# Verify the Docker imagegh attestation verify oci://ghcr.io/owner/repo:v1.0.0 --owner owner
# With slsa-verifierslsa-verifier verify-image ghcr.io/owner/repo:v1.0.0 \ --source-uri github.com/owner/repo \ --source-tag v1.0.0```An automatic verification workflow
Create .github/workflows/verify-slsa.yml to verify your releases
automatically, every night:
name: SLSA Verification
on: schedule: # Verify every night - cron: '0 2 * * *' workflow_dispatch:
permissions: {}
jobs: verify: runs-on: ubuntu-24.04 permissions: contents: read steps: - name: Install slsa-verifier run: | curl -LO https://github.com/slsa-framework/slsa-verifier/releases/latest/download/slsa-verifier-linux-amd64 chmod +x slsa-verifier-linux-amd64 sudo mv slsa-verifier-linux-amd64 /usr/local/bin/slsa-verifier
- name: Get the latest release id: latest env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | LATEST_TAG=$(gh release view --repo "$GITHUB_REPOSITORY" --json tagName -q .tagName) echo "tag=$LATEST_TAG" >> "$GITHUB_OUTPUT"
- name: Verify the SLSA provenance env: RELEASE_TAG: ${{ steps.latest.outputs.tag }} run: | slsa-verifier verify-image "ghcr.io/$GITHUB_REPOSITORY:$RELEASE_TAG" \ --source-uri "github.com/$GITHUB_REPOSITORY" \ --source-tag "$RELEASE_TAG" \ --print-provenance
- name: Report the status if: always() env: JOB_STATUS: ${{ job.status }} RELEASE_TAG: ${{ steps.latest.outputs.tag }} run: | if [ "$JOB_STATUS" = "success" ]; then echo "SLSA verification succeeded for $RELEASE_TAG" else echo "SLSA verification failed for $RELEASE_TAG" exit 1 fiThis workflow:
- Runs every night at 2am
- Fetches the latest release
- Verifies its SLSA attestation
- Fails if the verification does not pass
You can then show that workflow's badge in your README.
Automating it in Kubernetes
Verification can be enforced at admission: the cluster refuses any pod whose image has no valid attestation.
With Kyverno
Kyverno applies a policy requiring a GitHub keyless attestation on the images:
apiVersion: policies.kyverno.io/v1kind: ImageValidatingPolicymetadata: name: verify-attestationspec: validationActions: [Deny] matchConstraints: resourceRules: - apiGroups: [""] apiVersions: ["v1"] operations: ["CREATE", "UPDATE"] resources: ["pods"] matchImageReferences: - glob: "ghcr.io/owner/*" attestors: - name: github-keyless cosign: keyless: identities: - issuer: "https://token.actions.githubusercontent.com" subjectRegExp: "^https://github.com/owner/" validations: - expression: "true" message: "Image must have a valid SLSA provenance attestation."With the Sigstore Policy Controller
The Sigstore Policy Controller offers a ClusterImagePolicy with an equivalent
role:
apiVersion: policy.sigstore.dev/v1beta1kind: ClusterImagePolicymetadata: name: github-attestationspec: images: - glob: "ghcr.io/owner/**" authorities: - keyless: identities: - issuer: "https://token.actions.githubusercontent.com" subjectRegExp: "https://github.com/owner/.*"Common errors
Three failures come back often; each has a precise cause and a fix.
"No attestations found"
Error: no attestations found for digest sha256:abc123Possible causes:
- The artefact has no attestation generated
- The digest does not match
- The repository has not enabled attestations
"Verification failed"
Error: verification failed: signature mismatchPossible causes:
- The artefact was modified after signing
- The wrong file is being verified
- The attestation belongs to another artefact
"Certificate identity mismatch"
Error: certificate identity mismatchCause: the workflow that signed does not match the expected criteria.
Fix: check --signer-workflow or --certificate-identity-regexp.
Verification checklist
Before accepting a verification, go through this list; each point closes a way around it:
- Check that the SHA256 digest matches the artefact
- Check the source repository (
--owner,--repo) - Check the source workflow when it is critical (
--signer-workflow) - Check the expected version or tag
- Automate the verification in the deployment pipeline
Key points
- An unverified attestation protects nothing: verification is the step that closes the loop.
gh attestation verifyis the most direct route;cosignandslsa-verifiercover the advanced SLSA needs.- In CI/CD, verify before deploying: no unvalidated artefact should reach production.
- The
--print-provenanceoption reveals thebuildType, and therefore the SLSA level actually reached. - In Kubernetes, Kyverno or the Sigstore Policy Controller enforce verification at admission.
For the full reference, see the documentation of gh attestation verify and the slsa-verifier repository.
Next steps
- OpenSSF Scorecard: what this verification is worth concretely in the repository's posture score.
- Security checklist: the recap that places verification among the other gates before production.
- Supply chain attacks on GitHub Actions: the attacks that provenance verification actually stops.