Skip to content
Security Intermediate Tutorial

Sign and Verify Container Images with Cosign and Sigstore

Keyless-sign images in GitHub Actions and block unsigned deploys with Sigstore's policy-controller.

Ji-ho Choi
Ji-ho Choi
Security & Cloud Editor · Aug 4, 2026 · 6 min read
Sign and Verify Container Images with Cosign and Sigstore

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 kubectl and 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 settingsChange 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

  1. Cosign v3.1.1 release notes (bundle format and Rekor v2 defaults) — github.com
  2. Signing Containers - Sigstore Documentation — docs.sigstore.dev
  3. Kubernetes Policy Controller - Sigstore Documentation — docs.sigstore.dev
  4. Cosign v3 is now available — blog.sigstore.dev
  5. cosign-installer GitHub Action — github.com
  6. Rekor v2 GA - Cheaper to run, simpler to maintain — blog.sigstore.dev
Ji-ho Choi
Written by
Ji-ho Choi · Security & Cloud Editor

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

Join the discussion

Sign in or create an account to comment and vote.

No comments yet

Be the first to weigh in.

Related Reading