Why a digest isn't enough
Pinning to a digest (@sha256:..., covered on images & layers) guarantees you get exactly the bytes that digest was computed from -- nobody can swap the content underneath a digest without the digest itself changing. What it doesn't guarantee is that those bytes came from someone you trust. Anyone can build an image, push it, and hand you a perfectly valid digest for it. Integrity and authenticity are two different properties, and a digest alone only gives you the first.
How signing actually works
cosign, part of the Sigstore project, is the standard tool for this. A signature isn't embedded in the image -- it's stored as a separate artifact in the same registry, under a tag derived from the image's digest, so anyone can find and check it without trusting the image itself first.
There are two ways to sign:
- Key-based -- generate a long-lived keypair once, sign with the private key, verify with the public key. Simple, but that private key is now something you have to store and rotate carefully.
- Keyless -- cosign requests a short-lived certificate from Sigstore's own certificate authority (Fulcio), tied to an OIDC identity you already have (a GitHub Actions workflow, a Google account), and records the signature in a public transparency log (Rekor). No private key ever exists for more than a few minutes. This is the generally recommended approach now, especially from CI.
Signing an image
# Key-based: generate once, keep cosign.key private
cosign generate-key-pair
cosign sign --key cosign.key ghcr.io/you/app@sha256:<digest>
# Keyless, from a terminal -- opens a browser for OIDC login
cosign sign ghcr.io/you/app@sha256:<digest>
# Keyless, from GitHub Actions -- cosign picks up the workflow's
# own OIDC token automatically; --yes skips the interactive prompt
cosign sign --yes ghcr.io/you/app@sha256:<digest>Sign the digest, not a tag. A tag is mutable -- signing app:latest wouldn’t provably attest to any one set of bytes, since latest can point somewhere else tomorrow. The digest is the only reference that means "exactly this."
Verifying an image
cosign verify \
--certificate-identity="https://github.com/you/app/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/you/app@sha256:<digest>The identity and issuer flags aren’t optional extras -- they're the actual point. "Is this signed by someone" is nearly meaningless, since anyone can produce a keyless signature tied to their own identity. The real question is "is this signed by the specific workflow, repo, or person I trust to publish this image," and that's exactly what --certificate-identity pins down.
Verifying provenance and SBOM attestations
--provenance and --sbom on buildx build attach signed attestations the same way -- as separate, signed OCI artifacts alongside the image. cosign verify-attestation checks them the same way verify checks a plain signature.
cosign verify-attestation \
--type slsaprovenance \
--certificate-identity="https://github.com/you/app/.github/workflows/release.yml@refs/heads/main" \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
ghcr.io/you/app@sha256:<digest>Enforcing it, not just checking it by hand
Running cosign verify manually doesn’t stop anyone from deploying an unsigned image anyway -- it's just a check someone has to remember to run. The real enforcement point is the Kubernetes API server itself: an admission controller (Sigstore's own policy-controller, or an image-verification rule in Kyverno or OPA Gatekeeper) rejects a Pod outright if its image isn’t signed by an identity the policy names.
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-signature
match:
any:
- resources:
kinds: ["Pod"]
verifyImages:
- imageReferences:
- "ghcr.io/you/*"
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subject: "https://github.com/you/app/.github/workflows/release.yml@refs/heads/main"With this in place, a Pod spec referencing an unsigned (or wrong-identity-signed) image from ghcr.io/you/* never gets admitted -- not a warning in a dashboard somewhere, a hard rejection at the API server.
What this doesn't cover
A signature proves a specific identity produced this exact content -- it says nothing about whether that content is any good. A signed image can still have real vulnerabilities in it; signing and vulnerability scanning are complementary, not substitutes for each other. And the whole chain is only as trustworthy as the identity doing the signing -- if a CI pipeline's own credentials are compromised, it can sign a malicious image with a perfectly legitimate, correctly-verified signature. Signing moves the trust question from "do I trust this image" to "do I trust this specific pipeline," which is a real improvement, not a complete answer.