Sign and Verify Container Images with Cosign and Sigstore
Keyless-sign images in GitHub Actions and block unsigned deploys with Sigstore's policy-controller.
What you'll build
A GitHub Actions pipeline that builds a container image, pushes it to GHCR, and signs it with Cosign — no keys to generate, store, or rotate — plus a Kubernetes admission policy that refuses to run any image that wasn't signed by that exact workflow.
Prerequisites
Verified August 2026 against: Cosign v3.1.2, sigstore/cosign-installer v4.1.2, policy-controller Helm chart 0.10.6 (app v0.13.1), docker/build-push-action v7, docker/login-action v4, actions/checkout v7.
- A GitHub account and an empty repo named
cosign-demo(GHCR is included, free for public packages) - Cosign v3.1.2 locally:
brew install cosign, or a binary from the installation docs - A Kubernetes cluster you can install charts into — kind works fine — with
kubectland Helm v3 - macOS or Linux
How keyless works: your CI job requests an OIDC token from GitHub, Sigstore's Fulcio CA exchanges it for a certificate valid for ~10 minutes that embeds the workflow's identity, Cosign signs with an ephemeral key, and the signature is recorded in the Rekor transparency log. Verification checks the identity in the certificate, so "signed" means "produced by this workflow in this repo" — not "signed by whoever copied a key."
flowchart LR
A[GitHub Actions job] -- OIDC token --> B[Fulcio CA]
B -- short-lived cert --> A
A -- signature entry --> C[Rekor log]
A -- image + signature --> D[GHCR]
E[kubectl run] --> F[policy-controller webhook]
F -- reads signature --> D
F -- admit or deny --> G[cluster]
1. Create the demo image
In your cosign-demo repo, add a Dockerfile:
FROM alpine:3.24
CMD ["sleep", "infinity"]
2. Add the build-and-sign workflow
Create .github/workflows/build-sign.yml:
name: build-sign
on:
push:
branches: ["main"]
jobs:
build-sign:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write # lets the job mint the OIDC token Fulcio needs
steps:
- uses: actions/checkout@v7
- uses: sigstore/cosign-installer@v4
with:
cosign-release: 'v3.1.2'
- uses: docker/login-action@v4
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push
id: build
uses: docker/build-push-action@v7
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:latest
- name: Keyless-sign the image digest
env:
DIGEST: ${{ steps.build.outputs.digest }}
run: |
cosign sign --yes --new-bundle-format=false \
"ghcr.io/${{ github.repository }}@${DIGEST}"
Three deliberate choices here. Sign the digest, never the tag — tags are mutable, so signing one would vouch for whatever the tag points at later. --yes skips the interactive prompt and acknowledges that your repo and workflow name become part of a public transparency log. And --new-bundle-format=false matters: Cosign v3 switched its default to the new Sigstore bundle format attached via OCI referrers, but policy-controller currently only verifies plain signatures in the older tag-based format (bundle support exists for attestations only). The flag prints Flag --new-bundle-format has been deprecated in your logs — expected, and supported throughout the v3.x line. If you only verify with the Cosign CLI and don't need admission control, you can drop it.
Commit, push to main, and wait for the workflow to go green.
3. Make the package public
GHCR packages start private, which would leave both your cluster and the admission webhook unable to pull. On your repo's sidebar open the cosign-demo package → Package settings → Change visibility → Public. (For private images you'd instead wire imagePullSecrets into the policy and pods.)
4. Install policy-controller
The policy-controller is an admission webhook that resolves tags to digests and verifies signatures before pods are scheduled:
helm repo add sigstore https://sigstore.github.io/helm-charts
helm repo update
helm install policy-controller sigstore/policy-controller \
--version 0.10.6 -n cosign-system --create-namespace --wait
Enforcement is opt-in per namespace via a label:
kubectl create namespace demo
kubectl label namespace demo policy.sigstore.dev/include=true
5. Apply the signing policy
Save as policy.yaml, replacing YOURUSER (lowercase) in both places, then kubectl apply -f policy.yaml:
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-signed-images
spec:
images:
- glob: "**"
authorities:
- keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: https://token.actions.githubusercontent.com
subjectRegExp: '^https://github\.com/YOURUSER/cosign-demo/\.github/workflows/build-sign\.yml@refs/heads/main$'
ctlog:
url: https://rekor.sigstore.dev
The glob: "**" means every image in labeled namespaces must carry a signature from this one workflow on main — the strictest posture, and the easiest to demo. In production you'd scope globs per registry and add one authority per pipeline.
Verify it works
First from your laptop, with your GitHub username filled in:
cosign verify ghcr.io/YOURUSER/cosign-demo:latest \
--certificate-oidc-issuer=https://token.actions.githubusercontent.com \
--certificate-identity=https://github.com/YOURUSER/cosign-demo/.github/workflows/build-sign.yml@refs/heads/main
Expected output:
Verification for ghcr.io/YOURUSER/cosign-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
[{"critical":{"identity":{"docker-reference":"ghcr.io/YOURUSER/cosign-demo:latest"},"image":{"docker-manifest-digest":"sha256:..."},"type":"https://sigstore.dev/cosign/sign/v1"},"optional":{...}}]
Now prove the cluster blocks unsigned images:
kubectl -n demo run unsigned --image=alpine:3.24
Error from server (BadRequest): admission webhook "policy.sigstore.dev" denied the request:
validation failed: failed policy: require-signed-images: spec.containers[0].image
index.docker.io/library/alpine@sha256:... signature keyless validation failed for authority
authority-0 for index.docker.io/library/alpine@sha256:...: no matching signatures
And that your signed image sails through:
kubectl -n demo run signed --image=ghcr.io/YOURUSER/cosign-demo:latest
# pod/signed created
kubectl -n demo get pod signed
# NAME READY STATUS RESTARTS AGE
# signed 1/1 Running 0 10s
Troubleshooting
Your own signed image is rejected with no matching signatures. You almost certainly signed with Cosign v3's default bundle format — run cosign tree on the image: signatures listed as "artifacts via OCI referrer" are new-format bundles policy-controller can't see, while a sha256-*.sig tag is the legacy format it verifies. Re-run the workflow with --new-bundle-format=false on the sign step.
CI fails at the sign step with an error mentioning ACTIONS_ID_TOKEN_REQUEST_URL (or Cosign tries to open a browser). The job can't mint an OIDC token — add id-token: write under permissions:. Note GITHUB_TOKEN is unrelated; it can push to GHCR but is not an OIDC token.
cosign verify fails with no matching CertificateIdentity found, last error: expected SAN value to match regex "...", got "https://github.com/...". The identity is the full workflow path plus git ref, and it must match exactly. The got value shows what was actually signed — if it ends in @refs/tags/v1.0.0, your policy pinned refs/heads/main but the signature came from a tag build.
Build fails with ERROR: invalid reference format: repository name must be lowercase. GHCR requires lowercase image names, and ${{ github.repository }} preserves the case of your username. Hardcode the lowercase name or add a step that lowercases it before tagging.
Next steps
Signatures prove origin; attestations carry evidence — use cosign attest to attach SBOMs or SLSA provenance (these do work with policy-controller in the new bundle format via signatureFormat: bundle). Roll policies out safely by setting mode: warn before enforce, and scope glob per team or registry. Watch policy-controller releases for plain-signature bundle support so you can drop --new-bundle-format=false, and the Rekor v2 rollout for what changes in the transparency log under the hood. If GitHub's OIDC issuer doesn't fit your threat model, Sigstore's whole stack — Fulcio, Rekor, and the TUF root — can be self-hosted.
Sources & further reading
- Cosign v3.1.1 release notes (bundle format and Rekor v2 defaults) — github.com
- Signing Containers - Sigstore Documentation — docs.sigstore.dev
- Kubernetes Policy Controller - Sigstore Documentation — docs.sigstore.dev
- Cosign v3 is now available — blog.sigstore.dev
- cosign-installer GitHub Action — github.com
- Rekor v2 GA - Cheaper to run, simpler to maintain — blog.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.