InsightsSecurity
Signing container images with cosign and enforcing them at admission
How to sign images keylessly in CI with Sigstore cosign, verify who built them, and stop unsigned or wrongly signed images from running in Kubernetes with the Sigstore policy controller or Kyverno.
Published April 1, 20255 min read
A Kubernetes cluster will run any image it can pull. If an attacker can push to your registry, or a developer can deploy an image they built on their laptop, the cluster has no way to tell the difference between that and the image your pipeline produced after review and tests.
Image signing closes that gap. The pipeline signs each image it builds, and the cluster refuses to run images without a valid signature from that pipeline. Sigstore's cosign has made the first half almost free, and admission controllers make the second half enforceable.
Keyless signing
Traditional signing needs a private key: generated, stored somewhere safe, rotated, and protected from every pipeline that uses it. That is the same long-lived secret problem as a stored cloud access key.
Cosign's keyless mode removes the key. In CI, the job already has an OIDC identity token from its platform. Cosign generates a short-lived key pair in memory, presents the token to Fulcio, Sigstore's certificate authority, and receives a certificate valid for about ten minutes that binds the public key to the job's identity. It signs the image, records the signature in Rekor, Sigstore's public transparency log, and discards the private key.
What gets verified later is not "signed with key X" but "signed by this workflow, in this repository, on this branch, according to this OIDC issuer". That is a much more useful statement.
Signing in GitHub Actions
The job needs permission to request an OIDC token. Sign by digest, never by tag: a tag can be moved to a different image after signing, a digest cannot.
permissions:
contents: read
packages: write
id-token: write
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/acme/payments:${{ github.sha }}
- name: Sign image
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: cosign sign --yes ghcr.io/acme/payments@${DIGEST}The --yes flag skips the interactive confirmation about uploading to the public transparency log. That upload is worth thinking about once: the Rekor entry records the repository and workflow identity. For public projects that is the point. For private repositories, check that disclosing repository names is acceptable, or run a private Sigstore deployment.
The signature is stored in the registry next to the image, as an OCI artifact. Nothing else needs to be distributed.
Sign in the same job that builds and pushes, using the digest the build step reports. Signing in a later job, or pulling the image back by tag to sign it, opens a window in which a different image could be signed. If images are promoted between registries, copy the signature with the image rather than signing again in the target registry with a different identity.
Verifying by identity
Verification names the identity you expect, not just a valid signature:
cosign verify \
--certificate-identity "https://github.com/acme/payments/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/acme/payments@sha256:4f8c...Both flags matter. The issuer says which platform vouched for the identity. The identity says which workflow on which ref. Anyone can obtain a keyless signature for their own workflow; a policy that checks only for "some valid signature" proves nothing. When a pattern is needed, --certificate-identity-regexp exists, but anchor it tightly to your organization and the workflow file.
Enforcing at admission
Signing without enforcement is documentation. Two admission controllers are widely used for enforcement: the Sigstore policy controller and Kyverno.
Sigstore policy controller
The policy controller is Sigstore's own admission webhook. Policies are ClusterImagePolicy resources, and enforcement is opt-in per namespace with the label policy.sigstore.dev/include: "true".
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: payments-signed-by-release
spec:
images:
- glob: "ghcr.io/acme/**"
authorities:
- keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: https://token.actions.githubusercontent.com
subjectRegExp: "^https://github.com/acme/[a-z0-9-]+/\\.github/workflows/release\\.yml@refs/heads/main$"It is focused and simple to reason about. It also supports a warn mode per policy, useful while rolling out.
Kyverno
If Kyverno already enforces other policies in the cluster, its verifyImages rules avoid running a second webhook:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-acme-images
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: signed-by-release-workflow
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "ghcr.io/acme/*"
attestors:
- entries:
- keyless:
subjectRegExp: "^https://github.com/acme/[a-z0-9-]+/\\.github/workflows/release\\.yml@refs/heads/main$"
issuer: "https://token.actions.githubusercontent.com"
rekor:
url: https://rekor.sigstore.dev
mutateDigest: true
verifyDigest: trueWith mutateDigest, Kyverno rewrites tag references to the verified digest, so the node runs exactly the image that was checked. Both controllers do this, and it matters: without it, a tag could be repointed between verification and pull.
| Concern | Policy controller | Kyverno |
|---|---|---|
| Scope | Image verification only | General policy engine with image verification |
| Opt-in model | Namespace label | Match and exclude rules in each policy |
| Rollout mode | Warn per policy | Audit or Enforce per policy |
| Good fit | Teams wanting one focused control | Clusters already using Kyverno |
Operational details that bite
- Webhook availability. If the admission webhook is down and fails closed, no pods start, including the ones that would fix it. Run multiple replicas, exclude
kube-systemand the controller's own namespace, and decide deliberately between failing open and closed. - Third-party images. Many public images are signed by their publishers with keyless signatures; verify them against the publisher's identity. For images that are not signed, mirror them into your registry through a pipeline that scans and signs them.
- Air-gapped or private clusters need network access to Fulcio and Rekor roots, or an offline trust root configured in the controller.
- Latency. Verification adds a registry round trip on admission. Both controllers cache results, but expect the first pod of a new image to take slightly longer.
Rollout steps
- Add keyless signing by digest to the release workflow of every service. It costs nothing to run before enforcement exists.
- Verify a few images by hand with the exact identity and issuer you plan to enforce.
- Install the admission controller in audit or warn mode and collect the violations for a couple of weeks.
- Fix the sources of unsigned images: old pipelines, manual pushes, unmirrored third-party images.
- Switch to enforce, one namespace at a time, starting with production workloads that already pass.
- Restrict registry push access to the CI identity, so the only way into the cluster is through the signed path.