Introduction
An SBOM and a vulnerability scan tell you what is inside an image and whether it's exploitable. Neither tells you whether the image you're about to deploy is the exact artifact your pipeline built — or a tampered copy, a stale rebuild, or something an attacker pushed to the same tag after compromising a registry credential. That's the gap container image signing closes.
Sigstore is the open-source project (Linux Foundation, originally seeded by Google, Red Hat, and Chainguard) that made cryptographic signing practical for everyday supply-chain security — no more juggling long-lived PGP keys. Its CLI, Cosign, lets you:
- Sign a container image and attach the signature to the registry alongside it (no separate file to lose)
- Sign keylessly, using short-lived certificates issued off your CI identity (GitHub Actions OIDC token) instead of a private key you have to protect forever
- Verify a signature before a deployment proceeds, and reject the image if verification fails
- Enforce that policy automatically in Kubernetes with an admission controller
This guide covers both signing modes — a static key pair for local testing, and Sigstore's keyless flow for production CI — plus verification and a Kubernetes enforcement policy.
Prerequisites
- A Linux, macOS, or WSL2 shell with Docker installed
- Push access to a container registry (examples below use GHCR; Docker Hub works the same way)
- Outbound internet access (keyless signing talks to Sigstore's public Fulcio CA and Rekor transparency log)
- Optional: a Kubernetes cluster for the enforcement section (kind/minikube is fine)
Step 1: Install Cosign
Cosign ships a static binary for every platform:
# Linux
curl -sSfL https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64 \
-o /usr/local/bin/cosign
chmod +x /usr/local/bin/cosign
# macOS
brew install cosign
# Verify
cosign versionStep 2: Sign with a local key pair
Start with a static key pair to understand the primitives before moving to keyless signing.
# Generates cosign.key (private, password-protected) and cosign.pub (public)
cosign generate-key-pairBuild and push an image, then sign it by reference (digest, not tag — tags are mutable and can be repointed after signing):
docker build -t ghcr.io/myorg/myapp:1.0.0 .
docker push ghcr.io/myorg/myapp:1.0.0
cosign sign --key cosign.key ghcr.io/myorg/myapp:1.0.0Cosign resolves the tag to its digest, prompts for the key password, and pushes the signature to the registry as a separate OCI artifact tagged sha256-<digest>.sig — no changes to the image itself, and no external signature store to run.
Step 3: Verify a signed image
Anyone with the public key can verify the signature without ever touching the private key:
cosign verify --key cosign.pub ghcr.io/myorg/myapp:1.0.0A successful verification prints the signature payload (including the exact digest it covers) and exits 0. An unsigned image, a signature from the wrong key, or a retagged/tampered image all fail verification and exit non-zero — which is exactly the behavior you want a deploy pipeline to gate on.
Step 4: Keyless signing with Sigstore (production pattern)
Static keys reintroduce the problem signing was supposed to solve: a secret you now have to store, rotate, and protect from leaking. Sigstore's keyless flow sidesteps this by issuing a short-lived (10-minute) certificate off an OIDC identity — in CI, that's the workflow's own GitHub Actions token — instead of a long-lived key.
In a GitHub Actions workflow:
name: Build, Sign, and Push
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # required — this is what Fulcio exchanges for a cert
jobs:
build-sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Log in to GHCR
run: echo "${{ secrets.GITHUB_TOKEN }}" | docker login ghcr.io -u ${{ github.actor }} --password-stdin
- name: Build and push
run: |
docker build -t ghcr.io/${{ github.repository }}:${{ github.sha }} .
docker push ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Install Cosign
uses: sigstore/cosign-installer@v3
- name: Sign image (keyless)
run: cosign sign --yes ghcr.io/${{ github.repository }}:${{ github.sha }}--yes skips the interactive confirmation prompt (CI has no TTY). Cosign opens a browser-less OIDC exchange, Fulcio issues a short-lived signing certificate bound to the workflow's identity (repo, ref, and workflow file path), Cosign signs with it, and the signature plus certificate are pushed to the registry. The signing event is also recorded in Rekor, Sigstore's public transparency log — so the signing action itself is auditable even without anyone holding a key.
Verify a keyless signature by checking the identity it claims instead of a public key file:
cosign verify \
--certificate-identity-regexp "https://github.com/myorg/myapp/.github/workflows/.*" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp:abc1234This is the real security boundary of keyless signing: verification isn't "was this signed by some key" but "was this signed by this specific GitHub Actions workflow in this specific repo."
Step 5: Enforce signatures in Kubernetes
Signing and verifying by hand only helps if something blocks unsigned images from ever running. policy-controller (Sigstore's own admission controller, a trimmed fork of Kyverno's predecessor cosign policy webhook) rejects pods whose images fail verification.
# Install via Helm
helm repo add sigstore https://sigstore.github.io/helm-charts
helm install policy-controller sigstore/policy-controller \
-n cosign-system --create-namespaceDefine a ClusterImagePolicy that only admits images signed by your CI workflow:
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-ci-signature
spec:
images:
- glob: "ghcr.io/myorg/**"
authorities:
- keyless:
url: https://fulcio.sigstore.dev
identities:
- issuer: https://token.actions.githubusercontent.com
subjectRegExp: "https://github.com/myorg/myapp/.github/workflows/.*"kubectl apply -f require-ci-signature.yamlOnce the webhook is live, any kubectl apply or deployment that references an unsigned (or wrongly-signed) ghcr.io/myorg/* image is rejected at admission time — before the kubelet ever pulls it.
Verification / Testing
Confirm both the happy path and the rejection path work.
Signed image passes:
cosign verify --key cosign.pub ghcr.io/myorg/myapp:1.0.0 && echo "VERIFIED"An unsigned image fails cleanly:
docker pull alpine:latest
docker tag alpine:latest ghcr.io/myorg/unsigned-test:latest
docker push ghcr.io/myorg/unsigned-test:latest
cosign verify --key cosign.pub ghcr.io/myorg/unsigned-test:latest
echo "exit code: $?"You should see a no matching signatures error and a non-zero exit code — proving a CI gate built on this command will actually block an unsigned deploy. If you deployed the Kubernetes policy in Step 5, confirm enforcement directly:
kubectl run test-unsigned --image=ghcr.io/myorg/unsigned-test:latestThe request should be denied by the admission webhook with a signature-verification error, not silently scheduled.
Troubleshooting
cosign verifyfails with "no matching signatures" — confirm you signed and are verifying the same digest, not two different builds pushed to the same mutable tag. Usedocker inspect --format='{{index .RepoDigests 0}}'to check the actual digest.- Keyless signing hangs waiting for browser auth — that's expected outside CI; keyless signing needs an OIDC identity provider (GitHub Actions, Google, or a manual browser-based flow via
cosign signlocally). In CI, make sureid-token: writeis set in the workflow'spermissionsblock or the OIDC token request will fail silently. - Rekor upload errors ("connection refused" or timeouts) — Rekor is a public service with occasional rate limits; retry, or if your org requires an air-gapped setup, run a private Rekor/Fulcio instance (Sigstore supports this, but it's a separate deployment).
policy-controlleradmits images it shouldn't — double check theglobpattern matches your registry path exactly, and thatsubjectRegExpisn't too permissive (an unanchored regex can match more workflows than intended — anchor it with^...$or usesubjectfor an exact match).- Signature verifies locally but CI verification fails — CI runners often lack the Fulcio/Rekor public certificate chain cached; ensure
cosign-installerpulls a current Cosign release, since the root of trust (TUF-distributed) is embedded in the binary and updated periodically.
Summary
Cosign and Sigstore turn "is this the image we actually built" from an unanswerable question into a verifiable fact, without the key-management burden that killed most container-signing adoption before Sigstore existed. Start with a local key pair to learn the sign/verify flow, move signing into CI with keyless OIDC once you're comfortable, and finish by deploying policy-controller so unsigned images are rejected automatically instead of relying on someone remembering to run cosign verify by hand.