Objective
Supply chain attacks via compromised container images are a major threat vector. Cosign (part of the Sigstore project) provides image signing that creates an unforgeable link between a built image and its provenance. Kyverno enforces the signature requirement at admission time, blocking any image that lacks a valid signature from your approved keys. This creates a closed loop: only CI-built, signed images can run in your cluster.
Prerequisites
- Cosign CLI installed (github.com/sigstore/cosign/releases)
- Kyverno installed on your cluster
- Container registry access (Docker Hub, ECR, GHCR, or similar)
- An image you can push to your registry (we'll use nginx:alpine as a base)
Steps
Install Kyverno
# Install Kyverno via Helm helm repo add kyverno https://kyverno.github.io/kyverno/ helm repo update helm install kyverno kyverno/kyverno \ --namespace kyverno \ --create-namespace \ --set replicaCount=1 \ --wait kubectl get pods -n kyverno # kyverno-* should be Running
Generate a Cosign key pair
Generate an ECDSA key pair. The private key signs images during CI; the public key is used by Kyverno to verify signatures at admission time.
# Generate key pair (enter a passphrase when prompted) cosign generate-key-pair # This creates: # cosign.key — private key (keep this secret, store in CI secrets) # cosign.pub — public key (safe to commit to Git, share with Kyverno) # Store the public key as a Kubernetes Secret for Kyverno kubectl create secret generic cosign-public-key \ --from-file=cosign.pub \ --namespace kyverno
Tag and push a test image, then sign it
# Tag nginx as your "signed" image REGISTRY="ghcr.io/yourorg" # or docker.io/yourusername docker pull nginx:alpine docker tag nginx:alpine ${REGISTRY}/nginx-signed:v1.0.0 docker push ${REGISTRY}/nginx-signed:v1.0.0 # Sign the image with your private key # Cosign attaches the signature as an OCI artifact to the registry cosign sign \ --key cosign.key \ ${REGISTRY}/nginx-signed:v1.0.0 # Verify the signature is valid cosign verify \ --key cosign.pub \ ${REGISTRY}/nginx-signed:v1.0.0 # Expected output: # Verification for ghcr.io/yourorg/nginx-signed:v1.0.0 -- # The following checks were performed on each of these signatures: # - The cosign claims were validated # - The signatures were verified against the specified public key
Write the Kyverno ClusterPolicy for image verification
This policy does two things: (1) requires images to be signed with your key, and (2) restricts images to only your approved registry. Either violation causes admission denial.
# kyverno-image-verify.yaml cat << 'EOF' | kubectl apply -f - apiVersion: kyverno.io/v1 kind: ClusterPolicy metadata: name: verify-image-signatures annotations: policies.kyverno.io/title: Verify Image Signatures policies.kyverno.io/severity: high spec: validationFailureAction: Enforce # Block on violation background: false rules: - name: check-image-signature match: any: - resources: kinds: [Pod] namespaces: - production - staging verifyImages: - imageReferences: - "ghcr.io/yourorg/*" # Only verify images from your org attestors: - count: 1 entries: - keys: publicKeys: |- -----BEGIN PUBLIC KEY----- YOUR_COSIGN_PUBLIC_KEY_HERE -----END PUBLIC KEY----- - name: restrict-registries match: any: - resources: kinds: [Pod] namespaces: - production - staging validate: message: "Only images from ghcr.io/yourorg are allowed" pattern: spec: containers: - image: "ghcr.io/yourorg/*" EOF # Load the actual public key into the policy PUB_KEY=$(cat cosign.pub) kubectl patch clusterpolicy verify-image-signatures \ --type=json \ -p "[{\"op\":\"replace\",\"path\":\"/spec/rules/0/verifyImages/0/attestors/0/entries/0/keys/publicKeys\",\"value\":\"${PUB_KEY}\"}]"
Test: Deploy the signed image (should succeed)
kubectl create namespace production
cat << 'EOF' | kubectl apply -f -
apiVersion: v1
kind: Pod
metadata:
name: signed-app
namespace: production
spec:
containers:
- name: app
image: ghcr.io/yourorg/nginx-signed:v1.0.0
EOF
kubectl get pod signed-app -n production
# Expected: STATUS = RunningTest: Deploy an unsigned image (should be blocked)
# Try deploying nginx directly from Docker Hub — unsigned + wrong registry cat << 'EOF' | kubectl apply -f - apiVersion: v1 kind: Pod metadata: name: unsigned-app namespace: production spec: containers: - name: app image: nginx:latest EOF # Expected output: # Error from server: error when creating "STDIN": # admission webhook "mutate.kyverno.svc" denied the request: # resource Pod/production/unsigned-app was blocked due to the following policies: # verify-image-signatures: # restrict-registries: validation error: Only images from # ghcr.io/yourorg are allowed. rule restrict-registries failed
Check Kyverno policy reports
# View policy violation reports kubectl get policyreport -n production kubectl describe policyreport -n production # View admission controller audit events kubectl get events -n production \ --field-selector reason=PolicyViolation \ --sort-by='.lastTimestamp'
Success Criteria
Key Concepts
- Cosign keyless signing — in CI, use
cosign sign --identity-tokenwith OIDC for keyless signing tied to the GitHub Actions workflow identity - OCI signature attachment — signatures are stored in the same registry as the image, alongside the image manifest as a separate tag
- Kyverno validationFailureAction — Enforce blocks; Audit only reports. Use Audit when rolling out policies to existing clusters
- Image digest pinning — sign by digest (
@sha256:...), not by tag, to prevent tag mutation attacks
Further Reading
- Cosign documentation — docs.sigstore.dev/signing/quickstart
- Kyverno image verification — kyverno.io/docs/writing-policies/verify-images
- Sigstore project — sigstore.dev
- Supply chain security — slsa.dev