Enforce Signed Container Images in Kubernetes with Cosign and Kyverno

Updated on Sep 12, 2026
Mila H
10 MINS READ
Table of Contents
Sign container images with Cosign and Kyverno

Container images move through many hands before they reach your cluster, including a developer laptop, a CI runner, a registry, and finally a Kubernetes node. At any of these steps, an image can be swapped, tampered with, or replaced by something malicious. The fix is to sign container images with Cosign right after you build them, and then tell Kubernetes to reject anything that is not signed. This guide shows you the whole process.

Why You Should Sign Container Images with Cosign

Image signing proves who built the image, and whether it was changed after that. When you sign container images with Cosign, it adds a signature next to the image in the registry. Anyone with the public key can check that the image is untouched and really came from the signer.

This matters because Kubernetes will run any image it can pull, with no built-in check on who made it or whether it was modified.

Cosign and Kyverno fix this together. Cosign proves trust when the image is built, and Kyverno checks that trust when the image is deployed. No valid signature means the Pod never gets created.

What You Will Need

Before you start, make sure you have these things ready:

  • A Kubernetes cluster. A managed cluster, K3s, or a self-hosted cluster on a dedicated server all work.
  • kubectl configured to talk to that cluster, with cluster-admin rights.
  • Docker or another OCI-compatible builder installed on your machine.
  • A container registry you can push to, such as Docker Hub, GitHub Container Registry, or a private registry.
  • Kyverno already installed on your machine.

If you already set up Kyverno 1.19 from our Kyverno CEL policy guide, with the policy-lab and app-production namespaces, you can reuse that same cluster and the kyverno.perlod.com/security-tier=production label. This guide just adds a new policy type, ImageValidatingPolicy, on top of that. It does not replace your existing ValidatingPolicy rules for privileged containers, non-root users, or resource limits.

Step 1: Install Cosign

Cosign is the command-line tool from the Sigstore project that you use to sign container images with Cosign and verify signatures.

On a Linux machine (amd64), download and install the Cosign binary directly:

Bash
curl -O -L "https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64"sudo mv cosign-linux-amd64 /usr/local/bin/cosignsudo chmod +x /usr/local/bin/cosign

Check that Cosign installed correctly:

Bash
cosign version

You should see a version line similar to GitVersion: v3.1.3. If the command is not found, confirm /usr/local/bin is in your $PATH.

Step 2: Build and Push a Test Container Image

You need an image in a registry before you can sign it. This example uses a small Nginx-based image and GitHub Container Registry, but any registry works the same way.

Create a simple project folder and a minimal Dockerfile:

Bash
mkdir -p ~/cosign-demo && cd ~/cosign-demonano Dockerfile

Paste this into the file:

Bash
FROM nginx:1.27COPY index.html /usr/share/nginx/html/index.html

Create a small index.html file next to it:

Bash
echo "<h1>Signed with Cosign</h1>" > index.html

Log in to your registry, replace ghcr.io and the username with your own:

Bash
docker login ghcr.io -u your-github-username

Build and push the image:

Bash
docker build -t ghcr.io/your-github-username/demo-app:v1.0.0 .docker push ghcr.io/your-github-username/demo-app:v1.0.0

Cosign signs the exact image digest, not just the tag, so get the digest now with:

Bash
export IMAGE_DIGEST=$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/your-github-username/demo-app:v1.0.0)echo $IMAGE_DIGEST

You will use $IMAGE_DIGEST in the next steps. Signing by digest is the safer way to sign container images with Cosign because a tag can later point to a different image.

Step 3: Generate a Cosign Key Pair

For this guide, we will use key-based signing, which is the simplest way to get started. Set a password for the private key and generate the pair:

Bash
export COSIGN_PASSWORD=choose-a-strong-passwordcosign generate-key-pair

This creates two files in your current folder:

  • cosign.key: The private key. Keep this secret. Never commit it to Git.
  • cosign.pub: The public key. This is safe to share and is what Kyverno will use to check signatures.

Store cosign.key somewhere safe, such as a password manager or your CI/CD secret store. You will need cosign.pub in Step 8 to build the Kyverno policy.

Step 4: Sign the Image with Your Cosign Key

At this point, use the private key to sign container images with Cosign and upload the signature next to your image in the registry:

Bash
cosign sign --key cosign.key --yes $IMAGE_DIGEST

The --yes flag skips the confirmation prompt. Cosign will ask for the private key password unless COSIGN_PASSWORD is already set in your shell.

Once the command finishes, Cosign pushes the signature to the same registry repo as your image. There's no separate signing server or database needed; the registry itself holds the proof.

Step 5: Verify the Signature Manually

Before trusting Kubernetes to do this check, you must confirm the signature works from the command line:

Bash
cosign verify --key cosign.pub $IMAGE_DIGEST

If the signature is valid, Cosign prints the verified claims as JSON and exits with status 0. If you run this on an image you never signed, Cosign returns an error and a non-zero exit code. This is the same check Kyverno relies on inside the cluster.

Step 6: Add an SBOM Attestation (Optional)

Signing proves who published the image. An attestation goes one step further and attaches signed facts about the image, such as a software bill of materials (SBOM) or a vulnerability scan result. Kyverno can check attestations the same way it checks signatures.

You can create a simple SBOM-style predicate file. In a real pipeline, tools like Syft or Trivy generate this for you:

Bash
echo '{"packages": ["nginx=1.27", "openssl=3.x"]}' > sbom.json

Attach it as a signed attestation to the image:

Bash
cosign attest --key cosign.key --yes --type custom --predicate sbom.json $IMAGE_DIGEST

Verify the attestation locally:

Bash
cosign verify-attestation --key cosign.pub --type custom $IMAGE_DIGEST

Now you have both a signature and an attestation attached to the same image digest in the registry.

Step 7: Install Kyverno

If you already have Kyverno 1.19 running, skip this step. Otherwise, you can install it with Helm:

Bash
helm repo add kyverno https://kyverno.github.io/kyverno/helm repo updatehelm upgrade --install kyverno kyverno/kyverno \  --namespace kyverno \  --create-namespace \  --version v1.19.0

Wait for the admission controller to be ready:

Bash
kubectl rollout status deployment/kyverno-admission-controller -n kyverno --timeout=5mkubectl get pods -n kyverno

Confirm the ImageValidatingPolicy custom resource exists in your cluster:

Bash
kubectl get crd imagevalidatingpolicies.policies.kyverno.io

Step 8: Store the Cosign Public Key as a Kubernetes Secret

You can paste the public key directly inside the policy YAML, but keeping it in a Secret makes rotation easier and keeps large PEM blocks out of your policy files. Create the secret from the cosign.pub file you generated in Step 3:

Bash
kubectl create secret generic cosign-public-key \  --namespace kyverno \  --from-file=cosign.pub=cosign.pub

This guide keeps the key inline in the policy, which matches Kyverno's own examples and is simpler to follow. The Secret above becomes useful later, once you manage keys through GitOps.

Step 9: Create the ImageValidatingPolicy

Now you must write the policy that tells Kyverno how to check that you did sign container images with Cosign before letting them run. Print the public key so you can paste it into the YAML:

Bash
cat cosign.pub

Create the policy file:

Bash
mkdir -p ~/kyverno-1.19-production/policiesnano ~/kyverno-1.19-production/policies/07-verify-cosign-signature.yaml

Paste the following content. Replace the placeholder key with your own cosign.pub contents, and replace the registry glob with your image path:

YAML
apiVersion: policies.kyverno.io/v1kind: ImageValidatingPolicymetadata:  name: verify-cosign-signaturespec:  validationActions: [Deny]  failurePolicy: Fail  matchConstraints:    resourceRules:      - apiGroups: [""]        apiVersions: ["v1"]        operations: ["CREATE", "UPDATE"]        resources: ["pods"]  matchImageReferences:    - glob: "ghcr.io/your-github-username/*"  attestors:    - name: cosign      cosign:        key:          data: |            -----BEGIN PUBLIC KEY-----            PASTE-YOUR-COSIGN-PUBLIC-KEY-HERE            -----END PUBLIC KEY-----  validationConfigurations:    mutateDigest: true    verifyDigest: true    required: true  validations:    - expression: >-        images.containers.map(image,          verifyImageSignatures(image, [attestors.cosign])        ).all(e, e > 0)      message: "Image must be signed with the approved Cosign key before it can run."

Once you are done, apply the policy:

Bash
kubectl apply -f ~/kyverno-1.19-production/policies/07-verify-cosign-signature.yamlkubectl get imagevalidatingpolicy verify-cosign-signature

Note: If your cluster already uses the kyverno.perlod.com/security-tier=production label, add a namespaceSelector under matchConstraints. This keeps the image-signing rule scoped to the same namespaces as your other ValidatingPolicy rules.

Step 10: Verify Signatures Are Enforced

Now you can create a Pod manifest that points to the image you already signed:

Bash
nano ~/kyverno-1.19-production/workloads/signed-pod.yaml
YAML
apiVersion: v1kind: Podmetadata:  name: signed-demo  namespace: policy-labspec:  containers:    - name: demo      image: ghcr.io/your-github-username/demo-app:v1.0.0

Once you are done, apply it with:

Bash
kubectl apply -f ~/kyverno-1.19-production/workloads/signed-pod.yamlkubectl get pod signed-demo -n policy-lab

Because you already used Cosign to sign this image and pushed the signature to the registry, Kubernetes should accept the Pod and start it running normally.

Step 11: Prove Unsigned Images Are Rejected

At this point, you can try an image that was never signed. Any public image you have not run through Cosign will do:

Bash
kubectl run unsigned-demo \  --image=nginx:1.27 \  --namespace=policy-lab \  --restart=Never

If your matchImageReferences glob only covers your own registry, either change it to "*" for this test, or point the test Pod at an unsigned image inside your own registry instead. Once the policy matches that image, Kubernetes rejects the request with an error like this:

Bash
Error from server: admission webhook "ivpol.validate.kyverno.svc-fail" denied the request:Image must be signed with the approved Cosign key before it can run.

This confirms that when you sign container images with Cosign, they pass; when you skip that step, Kyverno blocks them. Clean up the test Pods when you are done:

Bash
kubectl delete pod signed-demo unsigned-demo -n policy-lab --ignore-not-found

Step 12: Check Attestations (Optional)

If you added an SBOM attestation in Step 6, you can require it too. Just add a second check using verifyAttestationSignatures, pointing to an attestations entry in the policy.

The exact fields depend on your attestation format, so check the official Kyverno reference before using this in production, because Kyverno's CEL API usually changes.

Signing with Keyless Identities in Cosign

The steps above use a long-lived key pair, which is the easiest way to learn to sign with Cosign. In a GitHub Actions pipeline, you can skip key management entirely and use keyless signing. 

In this way, Cosign uses the pipeline's own OIDC identity instead of a stored private key:

Bash
- name: Install Cosign  uses: sigstore/cosign-installer@main - name: Sign the image  run: cosign sign --yes ${{ env.IMAGE }}@${{ steps.build.outputs.digest }}

To verify a keyless-signed image, Cosign and Kyverno need the signer's identity and OIDC issuer instead of a public key. It requires a more advanced setup. 

Troubleshooting Common Cosign Errors

Even with the right steps, small mistakes can break the setup. Here are the most common errors and how to fix them:

  • "no matching signatures" during cosign verify: Double-check you are verifying the same digest you signed, not a newer build with the same tag.
  • Kyverno accepts everything, even unsigned images: Confirm validationActions is set to [Deny], not [Audit], and that matchImageReferences matches the image path you are testing.
  • Pod is stuck instead of rejected: Check kubectl describe pod <name> for the real admission error, and check Kyverno logs with kubectl logs -n kyverno deployment/kyverno-admission-controller --tail=200.
  • Policy applies but does nothing: Run kubectl get imagevalidatingpolicy verify-cosign-signature -o yaml and look at the status field for parsing or CEL expression errors.
  • Password prompt blocks CI scripts: Always set COSIGN_PASSWORD as an environment variable in automated pipelines, and pass --yes to cosign sign and cosign attest.

Conclusion

At this point, you have a working image trust pipeline. You sign container images with Cosign right after building, store the signature in the registry, and use Kyverno's ImageValidatingPolicy to enforce it at admission time. 

Next, you can try keyless signing in CI and SBOM attestations in production. Also, you can run this whole setup on your dedicated server hosting for full control over the registry, cluster, and policies.