Signing and Provenance: Sigstore, Artifact Attestations and SLSA v1.2
An image signature proves the content has not changed and who signed it, and provenance proves which repo, workflow and commit produced it; both only help if they are verified at deploy time. We verify keyless Sigstore signatures and GitHub attestations on real releases, bind an SBOM attestation to the Bookstore image, reject unsigned and tag-referenced images with Kyverno, and assess the pipeline against SLSA v1.2.
Version note: The code in this post was built and run with .NET 10; signing and verification were done with cosign 3.1.4, Kyverno CLI 1.20.0 and policy-controller tester 0.15.1, and the workflows were validated with actionlint 1.7.12.
Guess First
ghcr.io/bookstore/bookstore-api:1.4.0 runs in Bookstore's Kubernetes cluster. A token with write access to the registry leaks, and someone pushes their own image to the same tag. When the Pod restarts, which image does the cluster pull? If you were signing the image, who would notice, and when? And if the signing key were a secret in the pipeline, what would a signature prove once that secret leaked?
In this post we set up proof of where an artifact came from and who produced it: keyless signing with Sigstore, GitHub artifact attestations and SLSA (Supply-chain Levels for Software Artifacts) provenance, binding last post's SBOM to the image, an admission policy that rejects unsigned images at deploy time, and where the Bookstore pipeline stands against SLSA v1.2.
What a Signature Proves
An image signature answers three questions: has the content changed since it was signed (integrity), who signed it (identity), and under what conditions (which repo, which workflow, which commit). The first two are what classic signing does too. The third is provenance: a signed record of how the artifact was produced. "Did this image come out of our pipeline?" is really the third question; an image signed with the right key on a developer's laptop is also "signed with our key", but it did not come out of the pipeline.
The value of a signature depends entirely on verification. A signature nobody checks is just a line in a log. That is why half of this post is about signing and half about verifying.
Keyless Signing: Identity Instead of a Key
In classic signing you keep a private key and protecting it is your job. In Sigstore's keyless flow there is no long-lived key. The signing process, for example a GitHub Actions job, shows its OIDC (OpenID Connect) token to Fulcio, Sigstore's certificate authority; Fulcio issues a certificate for that identity that lasts minutes, the signature is made with that certificate's ephemeral key, and the record is written to Rekor, a public transparency log. The verifier looks not at a key but at the identity in the certificate: which OIDC issuer, which workflow, which ref.
In the previous post we verified the Trivy binary this way. I tried the same mechanism on Trivy's container image, with the expected workflow identity:
cosign verify ghcr.io/aquasecurity/trivy@sha256:8f6bb947dbc528902e11f2f5be52f7b519f6a39d5d148f41166f2040499768fb \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity "https://github.com/aquasecurity/trivy/.github/workflows/reusable-release.yaml@refs/tags/v0.76.2"
Verification for ghcr.io/aquasecurity/trivy@sha256:8f6bb947dbc528902e11f2f5be52f7b519f6a39d5d148f41166f2040499768fb --
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
I used a digest in the command instead of a tag. A tag can move; if you verify by tag and pull the image without a digest, what you verified and what you run can differ. We will see this on a local registry shortly.
GitHub Artifact Attestations and SLSA Provenance
GitHub's artifact attestations are built on the same infrastructure: the workflow puts the digest of the artifact it produced into an in-toto "statement", adds SLSA provenance (which workflow, which commit, which runner) and signs it with Sigstore. Public repos use Sigstore's public instance and private repos use GitHub's own Sigstore instance; according to the documentation, private and internal repos need GitHub Enterprise Cloud for this feature. I examined a real example: the v2.104.0 Linux package of the GitHub CLI. I found the attestation by digest through GitHub's REST (Representational State Transfer) API and looked at its contents:
jq -r '.dsseEnvelope.payload' gh-att-1.json | base64 -d \
| jq '{predicateType, builder: .predicate.runDetails.builder.id, commit: .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit, subjects: (.subject | length)}'
{
"predicateType": "https://slsa.dev/provenance/v1",
"builder": "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk",
"commit": "3d5a9e355a837f2f5ee78d7484c118cc1342ad1a",
"subjects": 21
}
A single attestation covers the release's 21 files with their digests and states which commit and which workflow produced each of them. The validity period of the certificate in the bundle was:
notBefore=Nov 12 19:01:55 2026 GMT
notAfter=Nov 12 19:11:55 2026 GMT
Ten minutes. Even if this certificate's key were stolen, it would be useless ten minutes later; the Rekor entry proves the signature was made within that window. That is the keyless answer to the problem of "storing a long-lived signing key". Normally verification is one gh attestation verify command; because I could not use gh on this machine without signing in, I verified the bundle with cosign:
cosign verify-blob-attestation gh_2.104.0_linux_amd64.tar.gz --bundle gh-att-1.json \
--type https://slsa.dev/provenance/v1 \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
--certificate-identity https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk
Verified OK
I ran two negative tests. When I wrote the expected identity as refs/heads/feature-x, verification was rejected:
Error: failed to verify certificate identity: no matching CertificateIdentity found, last error: expected SAN value "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/feature-x", got "https://github.com/cli/cli/.github/workflows/deployment.yml@refs/heads/trunk"
When I tried the same attestation against a different file, the error was provided artifact digests do not match digests in statement. One checks identity and the other integrity; together they prove the sentence "this file was produced by that repo's deployment workflow on trunk".
Bookstore's Release Job
At Bookstore, the steps that build the image, push it, extract its SBOM, sign it and produce its attestations are in a single job. The reason is the rule from the last post: the SBOM and the provenance must come from the same build as the signed digest.
name: release-image
on:
push:
branches: [main]
permissions:
contents: read
jobs:
release:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
attestations: write
artifact-metadata: write
env:
IMAGE: ghcr.io/bookstore/bookstore-api
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- uses: sigstore/cosign-installer@756fe2d284c7292c4e9b62c8a28f1e5629c59532 # v4.1.3
- uses: docker/login-action@8aa5831fb8d48d8f76594c7ddde61ae58019f550 # v4.8.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ github.token }}
- name: Build and push
id: push
run: |
docker build -t "$IMAGE:$GITHUB_SHA" .
docker push "$IMAGE:$GITHUB_SHA"
digest=$(docker inspect --format '{{index .RepoDigests 0}}' "$IMAGE:$GITHUB_SHA" | cut -d@ -f2)
echo "digest=$digest" >> "$GITHUB_OUTPUT"
- name: SBOM of the pushed image
uses: anchore/sbom-action@66cbf4bc1f1c0d2edc94016e65bc221b6bb0ad6c # v0.24.3
with:
image: ${{ env.IMAGE }}@${{ steps.push.outputs.digest }}
format: cyclonedx-json
output-file: bookstore-api-image.cdx.json
- name: Sign the image (keyless)
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: cosign sign --yes "$IMAGE@$DIGEST"
- name: Build provenance attestation
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
- name: SBOM attestation
uses: actions/attest@1e69f48acb82d1966a394da916b4c1698aa569d6 # v4.2.2
with:
subject-name: ${{ env.IMAGE }}
subject-digest: ${{ steps.push.outputs.digest }}
sbom-path: bookstore-api-image.cdx.json
push-to-registry: true
id-token: write is the permission to get the OIDC token for keyless signing; attestations: write and artifact-metadata: write are the permissions the actions/attest README asks for. The privileges exist only in this job, and the workflow level is contents: read; the mechanics of OIDC and least privilege are in the CI/CD Fundamentals series post "Pipeline Security and Secrets". Since v4 of actions/attest, attest-build-provenance and attest-sbom are thin wrappers on top of this action; the README recommends using actions/attest directly in new setups. I validated the workflow with actionlint and ran the line that extracts the digest against a local registry; I did not run it on GitHub, because keyless signing and attestations need a real Actions OIDC token.
Trying It Locally: Key-Based Signing, the SBOM Attestation and the Tag Trap
To see the flow end to end I started my own registry container, and because there is no OIDC here I used a cosign key pair for this experiment (the key stayed in the work folder). To keep the signature out of the public Rekor, I passed a signing config without a transparency log; in cosign 3, --tlog-upload=false is no longer accepted together with such a config:
IMG=localhost:5005/bookstore-api@sha256:31be559cfe5d4b76434920adb825ee99615c363624466ae715427f87e7ac5bad
cosign signing-config create --out offline-signing-config.json
cosign sign --key cosign.key --signing-config offline-signing-config.json --yes "$IMG"
cosign attest --key cosign.key --signing-config offline-signing-config.json --yes \
--type cyclonedx --predicate bookstore-api-image.cdx.json "$IMG"
I verified the SBOM attestation and looked at the statement inside:
cosign verify-attestation --key cosign.pub --insecure-ignore-tlog --type cyclonedx "$IMG" \
| jq -r '.payload' | base64 -d | jq '{predicateType, subject, components: (.predicate.components | length)}'
{
"predicateType": "https://cyclonedx.org/bom",
"subject": [
{
"name": "localhost:5005/bookstore-api",
"digest": {
"sha256": "31be559cfe5d4b76434920adb825ee99615c363624466ae715427f87e7ac5bad"
}
}
],
"components": 390
}
Last post's 390-component SBOM is now bound to the image's digest with a signature; anyone who takes the SBOM from the registry together with the image can verify that it belongs to this image and has not been changed. Then I played out the opening scenario: I added an extra file to the image and pushed it to the same 1.4.0 tag. The push succeeded, the tag pointed at the new digest (sha256:d6217b48...), and verifying by tag returned:
Error: no signatures found
The signature is bound to the digest; whoever moves the tag cannot move the signature. But this only helps if verification happens: if the cluster pulls by tag and nobody checks, the new image runs silently.
Verifying at Deploy Time: an Admission Policy
The most reliable place to verify is the cluster's door: before a Pod is created, Kubernetes asks admission webhooks, and a policy engine can check the image signature and reject the request. The mechanism builds on the admission concept from the Kubernetes series post "Kubernetes Security: ServiceAccounts, RBAC, Pod Security Standards, securityContext and NetworkPolicy". I ran an ImageValidatingPolicy with the Kyverno 1.20 CLI against Pod manifests without a cluster. Bookstore's policy trusts the public key from the local experiment (I shortened the key here):
apiVersion: policies.kyverno.io/v1
kind: ImageValidatingPolicy
metadata:
name: bookstore-signed-images
spec:
validationActions: [Deny]
matchConstraints:
resourceRules:
- apiGroups: [""]
apiVersions: ["v1"]
resources: ["pods"]
operations: ["CREATE", "UPDATE"]
matchImageReferences:
- glob: "localhost:5005/bookstore-api*"
attestors:
- name: pipeline
cosign:
key:
data: |
-----BEGIN PUBLIC KEY-----
...
-----END PUBLIC KEY-----
ctlog:
insecureIgnoreTlog: true
validations:
- expression: >-
images.containers.map(image, verifyImageSignatures(image, [attestors.pipeline])).all(e, e > 0)
message: "Image is not signed by the Bookstore pipeline"
I tried three Pods. With the signed digest, pass: 1. With the digest of the image with the extra file:
policy bookstore-signed-images -> resource bookstore/Pod/bookstore-api failed:
1 - bookstore-signed-images Image is not signed by the Bookstore pipeline; verification details: image "localhost:5005/bookstore-api@sha256:d6217b48e377980adcb625c89029560e3f352c4049b20878d83d5310bd75e8e3", attestor "pipeline": failed to verify cosign signatures: no signatures found
pass: 0, fail: 1, warn: 0, error: 0, skip: 0
The Pod with a tag was rejected without even looking at a signature: image localhost:5005/bookstore-api:1.4.0 does not have a digest. In production the attestor is keyless instead of key and asks for the issuer and the workflow identity. I tried this on Trivy's real image: when subjectRegExp pointed at Trivy's release workflow the Pod passed, and when I wrote the same regex as refs/heads/main the policy rejected it with expected SAN value to match regex. Here insecureIgnoreTlog exists only for the offline signature of the local experiment; a keyless policy verifies the Rekor entry.
I also found a compatibility trap. When I ran the same test with the tester of Sigstore's policy-controller (0.15.1), it returned no signatures found for the signed image. cosign 3 writes the signature in the new bundle format as an OCI referrer by default; this tester only found the signature once I also signed in the old .sig tag format, and it gave the same result on Trivy's public image. Kyverno 1.20 verified both formats. Before you switch a policy on, test with a real image that the signing tool and the verifying tool speak the same format. One more note: Kyverno 1.20 no longer runs the old kyverno.io/v1 ClusterPolicy type and asks you to move to the new CEL based types; most examples on the internet still use the old format.
SLSA v1.2: Where Does Bookstore Stand?
SLSA is a framework that describes supply chain assurance in levels; we touched on it briefly in the CI/CD Fundamentals series. v1.2, published in November 2025, added the Source track next to the existing Build track. On the Build track, L1 requires that provenance exists, L2 that provenance is generated and signed by a hosted build platform, and L3 that builds are isolated from each other and the signing secret is not reachable from user steps. On the Source track, L1 is version control, L2 is continuous, immutable history plus source provenance for every revision, L3 is technical controls continuously enforced on named branches, and L4 is two-party review on protected branches.
| Track | Bookstore today | Why | Next step |
|---|---|---|---|
| Build | L2 | GitHub-hosted runner, signed provenance with actions/attest | L3: move the build into a reusable workflow and require --signer-workflow on verification |
| Source | L1 | Code in Git, history on GitHub | Branch rulesets and two required approvals are the controls of L3/L4; L2's source provenance is needed as well |
GitHub's documentation defines default artifact attestations as SLSA v1.0 Build L2 and recommends building in a reusable workflow that also produces the attestation for L3; the Build track levels stayed the same in v1.2. The Source track needs the source control system to produce provenance for every revision; I could not verify whether GitHub provides this built in, so I count Bookstore at L1. Commit signing is related to this track but is not the same thing; its mechanics are in the Git and GitHub series post "Git Hooks and Commit Signing: From pre-commit to SSH Signatures".
The same idea exists in package ecosystems. NuGet.org puts a repository signature on every package; dotnet nuget verify --all verified both the author and the repository signature of Microsoft.AspNetCore.OpenApi 10.0.15 with Successfully verified package, and with trustedSigners you can accept only signers you trust at restore. On the npm side, provenance and trusted publishing are in the Frontend Security series post "Hardening the npm Supply Chain".
Production Notes
Start verification as a policy, then enforce it. Run Kyverno with validationActions: [Audit] for a week and see which images are unsigned; system images and third-party charts are usually the first surprise. Switch to Deny once the list is clean.
Keep the identity narrow. In a keyless policy, pin the issuer exactly and restrict the workflow identity to repo and ref; a broad regex like .* accepts any workflow of any repo on GitHub.
The admission webhook is a dependency. Choose deliberately what to do (failurePolicy) when the policy engine cannot reach Rekor, the registry or Fulcio's root certificates; letting an unverifiable image through puts security at risk, rejecting it puts availability at risk.
In private repos attestations are signed with GitHub's own Sigstore instance and do not go to a public transparency log; the verifier needs to know that trust root.
Common Pitfalls
First, signing and not verifying. A signature gains value when the system that deploys checks it.
Second, verifying by tag and running by tag. Verification and execution must look at the same digest; that is why Kyverno rejecting the Pod with a tag is a good default.
Third, keeping a long-lived signing key as a pipeline secret. When that secret leaks, an attacker can produce images that look like they "came from the pipeline"; in the keyless flow there is no permanent key to steal.
Fourth, writing the verification identity too broadly. A policy that checks only the issuer trusts everyone who runs on GitHub Actions.
Fifth, not testing the format compatibility of the signing and verifying tools. In this post the policy-controller tester did not see cosign 3's default signature format.
A Small Experiment
★☆☆ Verify the container image or release file of an open source tool you use with cosign and the correct identity, then deliberately write the identity wrong and read the error message.
★★☆ Start a local registry container, sign your own image with an offline signing config and add its SBOM as an attestation. Push another image to the same tag and see the difference between verifying by tag and by digest.
★★★ Write an ImageValidatingPolicy with the Kyverno CLI; test it with three Pod manifests: a signed digest, an unsigned digest and a tag. Then turn the policy into a keyless one and narrow the workflow identity to your own repo and main branch.
We have set up every control from secrets to signatures one by one. In the last post of the series we bring them all together in a single Bookstore workflow: fast checks on PRs, deep checks on main, gates that do not create noise, expiring exceptions and metrics that show whether this program actually works.