Block Unsigned Container Images with Cosign and Kyverno
Sign images keyless in GitHub Actions, then let Kyverno reject anything unsigned at admission.
What you'll build / learn
A GitHub Actions pipeline that builds a container image, pushes it to GHCR, and signs it keylessly with Cosign — plus a Kyverno admission policy that rejects any pod whose image isn't signed by that exact workflow identity. By the end, kubectl run with an unsigned image fails at admission.
flowchart LR
GA[GitHub Actions] -->|push + sign| GHCR[(ghcr.io)]
GA -->|OIDC cert| FUL[Fulcio / Rekor]
K[kubectl run] --> API[API server]
API --> KYV[Kyverno webhook]
KYV -->|fetch signature| GHCR
KYV -->|check identity + tlog| FUL
KYV -->|signed?| API
Prerequisites
Verified against these versions in August 2026:
- Cosign 3.1.2 (CLI, for local verification) —
brew install cosignor grab a release binary - Kyverno 1.18.2 installed via Helm 3.x
- kind v0.32.0 (Kubernetes v1.36 node image) — any cluster ≥ v1.28 with outbound internet access works; Kyverno must reach
ghcr.io,rekor.sigstore.dev, and the Sigstore TUF CDN - kubectl matching your cluster, Docker running locally
- A GitHub account (keyless signing uses the Actions OIDC token — no keys to generate or store)
One heads-up before you start: Cosign 3.x signs in the new Sigstore bundle format by default, and Kyverno can't verify that format with this policy type yet. The workflow below passes --new-bundle-format=false to produce the classic .sig tag signature Kyverno understands. Don't drop that flag.
1. Create the demo app and repository
Create a new GitHub repo (this tutorial assumes cosign-kyverno-demo) and give it a trivial Dockerfile:
FROM alpine:3
CMD ["sh", "-c", "echo 'signed and admitted'; sleep infinity"]
Everywhere you see YOURUSER below, substitute your GitHub username in lowercase — GHCR rejects uppercase image paths.
2. Add the build-and-sign workflow
Create .github/workflows/build-sign.yaml:
name: build-sign
on:
push:
branches: [main]
env:
IMAGE: ghcr.io/YOURUSER/cosign-kyverno-demo
jobs:
build-sign:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # lets cosign request the GitHub OIDC token
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
- uses: sigstore/cosign-installer@v4.0.0
- uses: docker/setup-buildx-action@v3.11.1
- uses: docker/login-action@v3.6.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6.18.0
id: push
with:
context: .
push: true
tags: ${{ env.IMAGE }}:latest
- name: Sign the image (keyless)
env:
DIGEST: ${{ steps.push.outputs.digest }}
run: |
cosign sign --yes \
--new-bundle-format=false \
--use-signing-config=false \
"${IMAGE}@${DIGEST}"
Three things matter here. id-token: write is what makes keyless work — Fulcio issues a short-lived signing certificate bound to this workflow's OIDC identity, so the workflow itself is the signer. Signing targets the digest, not the tag, because tags are mutable. And the two compatibility flags keep Cosign 3.x on the legacy signature path (registry .sig tag + Rekor v1 transparency log entry) that Kyverno verifies.
3. Push and make the image public
Commit and push to main, then watch the Actions run complete. The sign step logs a tlog entry created with index: … line — that's your Rekor transparency log receipt.
New GHCR packages are private, and Kyverno has no GHCR credentials, so make it public: on GitHub go to your profile → Packages → cosign-kyverno-demo → Package settings → Change visibility → Public. (For private registries you'd instead create a docker-registry secret and add --imagePullSecrets=<name> to the Kyverno admission controller deployment.)
4. Create a cluster and install Kyverno
kind create cluster --name signed-only
helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace
Wait until all four controllers are up:
kubectl -n kyverno wait pod --all --for=condition=Ready --timeout=180s
5. Apply the enforcement policy
Save as require-signed-images.yaml, substituting YOURUSER twice:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
webhookConfiguration:
timeoutSeconds: 30
background: false
rules:
- name: check-keyless-signature
match:
any:
- resources:
kinds:
- Pod
verifyImages:
- imageReferences:
- "*"
failureAction: Enforce
required: true
mutateDigest: true
attestors:
- entries:
- keyless:
subject: "https://github.com/YOURUSER/cosign-kyverno-demo/.github/workflows/*"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
kubectl apply -f require-signed-images.yaml
The subject is the identity Fulcio baked into the signing certificate — the workflow file's URL plus a @refs/heads/main suffix; the trailing * glob covers any workflow and ref in the repo. imageReferences: "*" enforces on every image in user namespaces (Kyverno's default config skips kube-system and its own namespace), which is exactly what you want in a demo cluster; in production, scope it to your registry like ghcr.io/yourorg/*. mutateDigest: true rewrites the pod's image tag to the verified digest, so what was verified is what runs. Verification happens in the admission webhook — increase timeoutSeconds if your egress is slow, since Kyverno makes live registry and Rekor calls.
Verify it works
First confirm the signature from your laptop:
cosign verify ghcr.io/YOURUSER/cosign-kyverno-demo:latest \
--certificate-identity-regexp 'https://github.com/YOURUSER/cosign-kyverno-demo/\.github/workflows/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
Expected output starts with:
Verification for ghcr.io/YOURUSER/cosign-kyverno-demo:latest --
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
followed by a JSON blob containing your workflow path in Issuer/Subject extensions. Now the real test — the signed image admits:
kubectl run signed --image=ghcr.io/YOURUSER/cosign-kyverno-demo:latest
# pod/signed created
kubectl get pod signed -o jsonpath='{.spec.containers[0].image}'
# ghcr.io/YOURUSER/cosign-kyverno-demo:latest@sha256:… ← digest pinned by mutateDigest
And an unsigned image is blocked:
kubectl run unsigned --image=nginx
Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
resource Pod/default/unsigned was blocked due to the following policies
require-signed-images:
check-keyless-signature: 'failed to verify image docker.io/nginx:latest:
.attestors[0].entries[0].keyless: no signatures found'
That admission denial is the whole point: unsigned provenance never reaches the kubelet.
Troubleshooting
the ACTIONS_ID_TOKEN_REQUEST_TOKEN environment variable was unset during cosign sign in CI — the job can't mint an OIDC token. Add id-token: write under the job's permissions block (a job-level permissions key replaces the defaults, so it's easy to lose when editing).
Kyverno rejects your signed image with no signatures found — you almost certainly signed with Cosign 3.x defaults, which store a new-format Sigstore bundle that this policy type can't read (tracked in Kyverno issue #16435). Re-run the workflow with --new-bundle-format=false --use-signing-config=false on the sign step, or pin cosign-release: 'v2.6.0' on cosign-installer.
UNAUTHORIZED: authentication required in the policy failure message — the GHCR package is still private, so Kyverno can't fetch the manifest or signature. Make the package public (Step 3) or wire registry credentials into the admission controller with --imagePullSecrets.
none of the expected identities matched what was in the certificate — your subject doesn't match the certificate identity. Run the cosign verify command above with --certificate-identity-regexp '.*' and read the Subject in the output; it must be covered by your policy glob. Renamed workflow files and non-main branches are the usual culprits.
Next steps
Signatures prove who built an image; attestations prove how. Add cosign attest with an SLSA provenance predicate in CI, then require it with a verifyImages attestations block. When you're ready to modernize, port this policy to Kyverno's ImageValidatingPolicy — the CEL-based replacement that went stable in 1.18 and also verifies GitHub Artifact Attestations via the SigstoreBundle type. In production, pin every GitHub Action to a commit SHA, scope imageReferences to your registries, and start with failureAction: Audit plus policy reports before flipping to Enforce.
Sources & further reading
- Sigstore CI Quickstart — docs.sigstore.dev
- Verify Images - Sigstore — kyverno.io
- Kyverno Installation Methods — kyverno.io
- Cosign v3 is now available — blog.sigstore.dev
- ImageValidatingPolicy cosign verification fails with new bundle format — github.com
- Verifying Signatures with Cosign — docs.sigstore.dev
Ji-ho covers the increasingly tangled overlap between cloud architecture and security, drawing on a background as a penetration tester to keep his reporting grounded in real-world attack paths. He never lets a vendor claim go unquestioned and insists that every buzzword come with a proof of concept.
Discussion 0
No comments yet
Be the first to weigh in.