← Back to Guide
Security & Hardening L2 · PRACTICAL ~60 min

Image Signing with Cosign and Kyverno Enforcement

Sign a container image with Cosign using a generated key pair. Write a Kyverno ClusterPolicy that blocks unsigned images and images from unapproved registries. Validate both signed and unsigned image scenarios at admission time.

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

Steps

01

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
02

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
03

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
04

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}\"}]"
05

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 = Running
06

Test: 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
07

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

Further Reading