SLSA Attestation Verification at Admission: Enforcing Build Provenance in Kubernetes
The Gap Between Generating and Enforcing
Generating SLSA provenance in CI is the easy part. The slsa-github-generator reusable workflow handles it in two dozen lines of YAML. The hard part is ensuring that provenance is actually verified before a workload runs in production. Without admission enforcement, a signed provenance document is metadata with no operational teeth: a developer can push an unsigned image directly to the registry, deploy it via kubectl, and the provenance record in the OCI referrers store is never consulted.
The enforcement point is the Kubernetes admission webhook. Every Pod creation — whether from a Deployment rollout, a Helm chart, or a manual kubectl run — passes through the admission chain. A policy engine sitting at that point can demand that every image in every container spec carries a verified SLSA provenance attestation before it is allowed to run. If the attestation is missing, invalid, or signed by an untrusted builder, the Pod is rejected.
This article focuses on the enforcement side: how provenance gets attached to images as OCI referrers, how admission controllers verify those attestations at deploy time, and how to build a layered policy that applies different provenance requirements to production and development namespaces.
Prerequisites: familiarity with SLSA build provenance generation and cosign container signing.
Attaching Provenance as an OCI Attestation
The slsa-github-generator generator_container_slsa3.yml workflow automatically pushes provenance into the registry. For teams that need to attach provenance manually — using a custom builder or a third-party SLSA generator — the mechanism is cosign attest.
What cosign attest does
cosign attest takes a predicate file, wraps it in a DSSE-signed in-toto Statement, and pushes the result to the registry as an OCI referrer. The attestation is stored as a separate manifest with an artifactType of application/vnd.in-toto+json, linked to the subject image digest via the OCI referrers API (the _oras/referrers or referrers tag scheme, depending on registry support). The original image is not modified.
cosign attest \
--predicate provenance.json \
--type slsaprovenance1 \
--certificate-identity "https://github.com/myorg/myapp/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@sha256:4a7c8b2d...
The --type slsaprovenance1 flag sets the predicateType in the in-toto Statement to https://slsa.dev/provenance/v1. Other recognized type aliases: slsaprovenance (v0.2), spdxjson, cyclonedx, vuln.
For keyless signing inside GitHub Actions, omit the certificate flags — cosign picks up the OIDC token automatically from the Actions environment:
- name: Attach SLSA provenance attestation
run: |
cosign attest \
--predicate provenance.json \
--type slsaprovenance1 \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
env:
COSIGN_EXPERIMENTAL: "1"
The attestation is now discoverable via the OCI referrers API at any registry that implements OCI Distribution Spec 1.1. For registries that don’t — notably some older ECR and Artifact Registry configurations — cosign falls back to the sha256-<hex>.att tag convention.
in-toto Statement and SLSA Provenance Predicate
Understanding the wire format is necessary for writing correct JMESPath conditions in admission policies. The full structure after DSSE decoding:
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{
"name": "ghcr.io/myorg/myapp",
"digest": { "sha256": "4a7c8b2d..." }
}
],
"predicateType": "https://slsa.dev/provenance/v1",
"predicate": {
"buildDefinition": {
"buildType": "https://slsa-framework.github.io/github-actions-buildtypes/workflow/v1",
"externalParameters": {
"workflow": {
"ref": "refs/heads/main",
"repository": "https://github.com/myorg/myapp",
"path": ".github/workflows/release.yml"
}
},
"internalParameters": {
"github": {
"event_name": "push",
"repository_id": "123456789",
"repository_owner_id": "987654321"
}
},
"resolvedDependencies": [
{
"uri": "git+https://github.com/myorg/myapp@refs/heads/main",
"digest": { "gitCommit": "5f3a8b2c1d9e4f7a..." }
}
]
},
"runDetails": {
"builder": {
"id": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
},
"metadata": {
"invocationId": "https://github.com/myorg/myapp/actions/runs/12345678/attempts/1",
"startedOn": "2026-05-09T10:00:00Z",
"finishedOn": "2026-05-09T10:03:42Z"
}
}
}
}
The fields that matter for policy enforcement:
predicate.runDetails.builder.id— the builder identity. This is the string a Kyverno JMESPath condition checks to distinguish SLSA L3 from anything lesser.predicate.buildDefinition.externalParameters.workflow.repository— the source repository. Must match the expected org/repo to block builds from forks.predicate.buildDefinition.externalParameters.workflow.ref— the Git ref. Enforce that production images come fromrefs/heads/mainor a release tag, not a feature branch.predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit— the exact commit SHA that triggered the build.
Verifying Attestations with cosign verify-attestation
Before writing admission policies, confirm the attestation is present and valid manually:
cosign verify-attestation \
--type slsaprovenance1 \
--certificate-identity-regexp \
"https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@sha256:4a7c8b2d...
cosign verify-attestation does four things in sequence: fetches the OCI referrer, verifies the DSSE signature against the certificate chain, checks Rekor for a timestamp inclusion proof, and returns the decoded Statement on stdout. A zero exit code means all four checks passed.
Pipe to jq to inspect specific predicate fields:
cosign verify-attestation \
--type slsaprovenance1 \
--certificate-identity-regexp \
"https://github.com/slsa-framework/slsa-github-generator/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@sha256:4a7c8b2d... \
| jq -r '.payload' | base64 -d | jq '{
builder: .predicate.runDetails.builder.id,
repo: .predicate.buildDefinition.externalParameters.workflow.repository,
ref: .predicate.buildDefinition.externalParameters.workflow.ref,
commit: .predicate.buildDefinition.resolvedDependencies[0].digest.gitCommit
}'
Expected output for a valid SLSA L3 container image:
{
"builder": "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0",
"repo": "https://github.com/myorg/myapp",
"ref": "refs/heads/main",
"commit": "5f3a8b2c1d9e4f7a0b3c6d8e1f2a4b5c7d9e0f1a"
}
If the attestation is missing: cosign verify-attestation exits non-zero with no matching attestations. If the signature is invalid: error verifying certificate. Both are fatal — the image must not be deployed.
Kyverno ClusterPolicy for Attestation Verification
Kyverno implements the verifyImages rule type, which handles OCI attestation fetching, DSSE signature verification, and predicate field inspection in a single policy rule. No external webhook call required.
Production namespace: require SLSA L3
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-slsa-l3-production
annotations:
policies.kyverno.io/title: Require SLSA Build Level 3 Provenance
policies.kyverno.io/severity: high
spec:
validationFailureAction: Enforce
background: false
webhookTimeoutSeconds: 30
rules:
- name: verify-slsa-l3-attestation
match:
any:
- resources:
kinds: [Pod]
namespaces: [production]
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
mutateDigest: true
verifyDigest: true
required: true
attestations:
- predicateType: https://slsa.dev/provenance/v1
attestors:
- count: 1
entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
rekor:
url: "https://rekor.sigstore.dev"
conditions:
- all:
- key: "{{ predicate.runDetails.builder.id }}"
operator: Equals
value: "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
- key: "{{ predicate.buildDefinition.externalParameters.workflow.repository }}"
operator: Equals
value: "https://github.com/myorg/myapp"
- key: "{{ predicate.buildDefinition.externalParameters.workflow.ref }}"
operator: AnyIn
value:
- "refs/heads/main"
- "refs/tags/v*"
mutateDigest: true is non-optional for production. It rewrites image: myapp:latest to image: myapp@sha256:... before verification, ensuring the digest Kyverno verifies is the digest that the kubelet will pull. Without this, a race condition exists between tag resolution at verification time and tag resolution at pull time.
required: true on the attestations block means a Pod is rejected if no matching attestation exists at all — not just if an existing attestation fails verification.
background: false disables background scanning of existing resources, limiting enforcement to admission time. Background scanning with SLSA verification generates significant registry traffic and is not usually appropriate for a webhook with a 30-second timeout.
Development namespace: allow SLSA L1
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-slsa-l1-dev
spec:
validationFailureAction: Enforce
background: false
rules:
- name: verify-slsa-l1-attestation
match:
any:
- resources:
kinds: [Pod]
namespaces: [dev, staging]
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
mutateDigest: true
verifyDigest: true
required: true
attestations:
- predicateType: https://slsa.dev/provenance/v1
attestors:
- count: 1
entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "https://github.com/myorg/myapp/.*"
rekor:
url: "https://rekor.sigstore.dev"
conditions:
- all:
- key: "{{ predicate.buildDefinition.externalParameters.workflow.repository }}"
operator: Equals
value: "https://github.com/myorg/myapp"
The dev policy accepts any attestation signed by any workflow in the myorg/myapp repository, with no constraint on the builder ID. This allows L1 or L2 provenance generated by standard workflow runs — feature branches, PR builds — without requiring the isolated slsa-github-generator job. The production policy’s builder.id check is what specifically gates on L3.
What SLSA L3 means in the conditions block
The builder.id check is load-bearing. SLSA Build L3 is not a flag in the provenance document — it is a property of the builder that generated the provenance. The slsa-github-generator workflows at specific tagged versions are designated SLSA L3 builders because they:
- Run in a separate GitHub Actions job with a distinct OIDC token that user build code cannot access.
- Pin their own dependencies by digest, preventing dependency substitution attacks.
- Generate provenance from inputs passed via job outputs, not via environment variables that user code can write.
- Use ephemeral signing keys tied to the OIDC token, with no persistent credentials exposed to the build.
When the Kyverno condition checks that predicate.runDetails.builder.id equals the exact URL of the pinned slsa-github-generator workflow, it is asserting that these properties held. Any provenance generated by a user-controlled workflow — even one that imports the same signing libraries — will have a different builder.id that does not match.
Blocking pinned versions: if v2.0.0 of slsa-github-generator has a known vulnerability, update both the workflow reference in CI and the subject and key values in the Kyverno policy simultaneously. A lag between these two updates means either CI fails or the policy accepts images from the old, potentially vulnerable builder.
Using Ratify with ORAS as an Alternative Verifier
Ratify is a CNCF project that implements verification of OCI referrers — signatures, attestations, SBOMs — as an external data provider for Gatekeeper and as a standalone admission webhook. It uses the ORAS library for OCI referrer discovery.
Ratify architecture
Ratify runs as a Deployment in the cluster. Gatekeeper’s ExternalData feature routes image verification requests to Ratify, which fetches referrers from the registry, runs verifier plugins, and returns a pass/fail result with structured metadata. Gatekeeper then evaluates the result against a Rego policy.
Install Ratify:
helm repo add ratify https://ratify-project.github.io/ratify
helm repo update
helm install ratify ratify/ratify \
--namespace gatekeeper-system \
--set featureFlags.RATIFY_CERT_ROTATION=true \
--set provider.enableMutation=true
Ratify verifier configuration for SLSA provenance
apiVersion: config.ratify.deislabs.io/v1beta1
kind: Verifier
metadata:
name: slsa-provenance-verifier
spec:
name: notation
artifactTypes: application/vnd.in-toto+json
parameters:
verifierType: slsa
trustPolicyDoc:
version: "1.0"
trustPolicies:
- name: production-policy
registryScopes:
- "ghcr.io/myorg/*"
signatureVerification:
level: strict
trustStores:
- sigstore:sigstore
trustedIdentities:
- "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
Ratify’s SLSA verifier plugin runs slsa-verifier verify-image internally against the image digest and the fetched provenance referrer, then returns a structured result. The Gatekeeper policy consumes the result via an ExternalData template:
apiVersion: templates.gatekeeper.sh/v1alpha1
kind: AssignMetadata
metadata:
name: ratify-slsa-check
spec:
match:
scope: Namespaced
kinds: [{ apiGroups: ["*"], kinds: ["Pod"] }]
location: "metadata.annotations.ratify-slsa-verified"
parameters:
assign:
externalData:
provider: ratify
dataSource:
type: ValueAtLocation
location: "spec.containers[_].image"
failurePolicy: Fail
Ratify is heavier than Kyverno’s built-in verifyImages but provides a richer plugin ecosystem: separate verifier plugins for notation signatures, cosign signatures, SLSA provenance, SBOM presence, and custom predicates. It also surfaces per-image verification results as structured metadata that downstream policies can introspect.
Full CI Pipeline: Code Push to Production SLSA L3 Image
name: Build, Sign, and Attest (SLSA L3)
on:
push:
branches: [main]
tags: ["v*"]
permissions:
contents: read
packages: write
id-token: write
actions: read
jobs:
build:
runs-on: ubuntu-latest
outputs:
image: ${{ steps.meta.outputs.tags }}
digest: ${{ steps.push.outputs.digest }}
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
- uses: docker/setup-buildx-action@b5ca514318bd6ebac0fb2aedd5d36ec1b5c232a2
- uses: docker/login-action@9780b0c442fbb1117ed29e0efdff1e18412f7567
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/metadata-action@369eb591f429131d6889c46b94e711f089e6ca96
id: meta
with:
images: ghcr.io/${{ github.repository }}
- uses: docker/build-push-action@4f58ea79222b3b9dc2c8bbdd6debcef730109a75
id: push
with:
context: .
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
sbom: false
provenance: false
sign:
needs: build
runs-on: ubuntu-latest
steps:
- uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da
- name: Sign image
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
provenance:
needs: build
permissions:
actions: read
id-token: write
packages: write
uses: slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@v2.0.0
with:
image: ghcr.io/${{ github.repository_owner }}/${{ github.event.repository.name }}
digest: ${{ needs.build.outputs.digest }}
secrets:
registry-username: ${{ github.actor }}
registry-password: ${{ secrets.GITHUB_TOKEN }}
verify-before-promote:
needs: [sign, provenance]
runs-on: ubuntu-latest
steps:
- uses: sigstore/cosign-installer@dc72c7d5c4d10cd6bcb8cf6e3fd625a9e5e537da
- name: Install slsa-verifier
run: |
go install github.com/slsa-framework/slsa-verifier/v2/cli/slsa-verifier@v2.6.0
- name: Verify SLSA provenance before tagging latest
run: |
slsa-verifier verify-image \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} \
--source-uri github.com/${{ github.repository }} \
--source-branch main \
--builder-id "https://github.com/slsa-framework/slsa-github-generator/.github/workflows/generator_container_slsa3.yml@refs/tags/v2.0.0"
- name: Tag as production-ready
run: |
cosign copy \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }} \
ghcr.io/${{ github.repository }}:production
The verify-before-promote job runs slsa-verifier in CI before the image is tagged for production. This is a defence-in-depth measure — it catches broken provenance before the image ever reaches the admission webhook, surfacing failures earlier in the delivery pipeline. The Kyverno admission policy is still the authoritative enforcement point; this job is an early-warning check.
The sign and provenance jobs are independent and can run in parallel after build. The verify-before-promote job waits for both. The provenance job uses the pinned reusable workflow from slsa-github-generator, which runs in a separate GitHub Actions context with its own OIDC token — the property that makes it SLSA L3.
Testing Admission Policy Enforcement
Before putting a policy into Enforce mode, test it in Audit mode against existing workloads:
kubectl label namespace production \
policy.kyverno.io/validate-mode=audit
kubectl get policyreport -n production -o yaml | \
yq '.items[].results[] | select(.result == "fail")'
Test that a Pod without attestation is blocked:
kubectl run test-unsigned \
--image=ghcr.io/myorg/myapp:unsigned-test \
--namespace=production \
--dry-run=server
# Expected: Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request
Test that a valid image passes:
kubectl run test-signed \
--image=ghcr.io/myorg/myapp@sha256:4a7c8b2d... \
--namespace=production \
--dry-run=server
# Expected: pod/test-signed created (server dry run)
Check webhook latency — SLSA attestation verification fetches from the registry:
kubectl get --raw /metrics | grep kyverno_admission_request_duration
Attestation verification adds 1-5 seconds per unique image digest, depending on registry latency. For Pods with multiple containers referencing the same digest, Kyverno caches the result within the admission request. Set webhookTimeoutSeconds: 30 and configure the Kyverno webhook failure policy appropriately — Fail for production, possibly Ignore for emergency namespaces with a break-glass procedure.
Gaps and Limitations
SLSA covers build integrity, not runtime behaviour. A SLSA L3 provenance attestation proves that a specific binary was produced from a specific source by a specific builder. It says nothing about what that binary does when running. A supply chain attack that inserts malicious code into a dependency before the build — and is therefore accurately represented in the provenance — passes SLSA verification cleanly. SLSA is necessary but not sufficient; it belongs alongside runtime security controls, not instead of them.
If the builder is compromised, attestations can be forged. The SLSA L3 guarantee depends on the builder — the slsa-github-generator workflow and the GitHub Actions platform — being trustworthy. A compromise of GitHub Actions’ OIDC issuer, the Fulcio CA, or the slsa-github-generator codebase would allow an attacker to generate provenance that verifies against any policy using those trust roots. This is not a hypothetical: the supply chain attack surface for the builder itself is real. Ratify supports private Rekor instances and custom trust roots for organizations that need air-gapped or private trust hierarchies.
Attestations are attached to digests, not to tags. A cosign attest against ghcr.io/myorg/myapp:latest attaches to the digest that latest resolved to at attestation time. If latest is re-pointed to a different digest after the fact, the new digest has no attestation. The Kyverno mutateDigest: true option handles this for incoming Pods by resolving tags to digests before admission, but it does not prevent unattested images from existing in the registry.
The policy is only as good as the builder ID allowlist. If the slsa-github-generator releases a new major version (v3.0.0) with a different workflow path, the Kyverno condition must be updated. A mismatch means either legitimate images are blocked (stale policy) or images built with the old version continue to be accepted indefinitely (stale builder reference). Automate policy updates alongside builder version bumps — treat them as a coupled release.
Namespace-based policies require namespace labels to be trusted. A match.resources.namespaces selector in Kyverno matches the namespace name. If developers can create arbitrary namespaces — including ones named production — they can work around namespace-based policies. Lock down namespace creation with a separate policy and ensure namespace labels are managed exclusively via GitOps. See container image signing policy for namespace protection patterns.
OCI referrers support is inconsistent across registries. The OCI Distribution Spec 1.1 referrers API is not universally implemented. ECR, Docker Hub, and some self-hosted registries fall back to the cosign tag convention (sha256-<hex>.att). Kyverno and Ratify handle both, but verify that your specific registry supports the scheme you rely on — particularly for private registries that may be running older versions.