Container images are the artifact most organisations deploy, and the one where the gap between “we built it” and “this is what is running” is widest — because the reference in your deployment configuration is almost always a tag, and a tag is a pointer that can be moved.
The short answer
Section titled “The short answer”Sign the digest, never the tag.
{/* Keyless, in CI, using the workflow's OIDC identity */}cosign sign --yes ghcr.io/YOUR_ORG/YOUR_IMAGE@sha256:YOUR_DIGESTVerify with an identity constraint, or you have verified nothing useful:
cosign verify ghcr.io/YOUR_ORG/YOUR_IMAGE@sha256:YOUR_DIGEST \ --certificate-identity-regexp "^https://github.com/YOUR_ORG/YOUR_REPO/" \ --certificate-oidc-issuer https://token.actions.githubusercontent.comEnforce at admission, so the check covers everything reaching the cluster rather than only what your pipeline sends.
Digests and tags
Section titled “Digests and tags”The distinction underpins everything else on this page.
A tag is a mutable pointer. myapp:v1.2.3 points at an image today. Anybody with push access can
repoint it tomorrow, and nothing about the reference changes. Every system referencing that tag now
gets something different.
A digest is the content. myapp@sha256:abc123… is the SHA-256 of the image manifest. It cannot be
repointed, because changing the content produces a different digest.
docker buildx imagetools inspect ghcr.io/YOUR_ORG/YOUR_IMAGE:TAG --format '{{.Manifest.Digest}}'What it doesPrints the digest of an image in a registry without pulling it.
Why we run itThe digest is the only stable identifier for an image; a tag is a label that can move.
Expected resultA line of the form ghcr.io/org/image@sha256:….
The consequences run through the whole pipeline:
| Practice | Consequence |
|---|---|
| Signing a tag | The signature describes whatever the tag pointed at when you signed |
| Deploying a tag | You deploy whatever it points at now |
| Promoting a tag | Staging and production can silently diverge |
| Deploying a digest | You deploy exactly what was built, tested and signed |
Tags are for humans. Digests are for machines. A deployment manifest with a tag in it is a deployment whose content is decided elsewhere.
Signing in a build
Section titled “Signing in a build”permissions: contents: read packages: write id-token: write
jobs: build-and-sign: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7
- uses: docker/login-action@v4 with: registry: ghcr.io username: ${{ github.actor }} password: ${{ secrets.GITHUB_TOKEN }}
- id: build uses: docker/build-push-action@v7 with: push: true tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- uses: sigstore/cosign-installer@v4
- name: Sign the image by digest run: cosign sign --yes "ghcr.io/${REPO}@${DIGEST}" env: REPO: ${{ github.repository }} DIGEST: ${{ steps.build.outputs.digest }}Three things make this correct rather than approximately correct.
id-token: write enables keyless signing. Without it Cosign has no OIDC identity to present, and
the failure reads as a generic permissions problem.
The digest comes from the build step’s output. Not from the tag it just pushed. Between the push and the sign, a tag could be repointed; the digest cannot.
--yes skips the interactive confirmation, which is required in a non-interactive environment and
is a deliberate acknowledgement that the signing event will be recorded in a public transparency log.
GitHub attestations for images
Section titled “GitHub attestations for images”The alternative to Cosign, with less to operate:
permissions: contents: read packages: write id-token: write attestations: write
steps: - id: build uses: docker/build-push-action@v7 with: push: true tags: ghcr.io/${{ github.repository }}:${{ github.ref_name }}
- uses: actions/attest@v4 with: subject-name: ghcr.io/${{ github.repository }} subject-digest: ${{ steps.build.outputs.digest }} push-to-registry: truepush-to-registry: true stores the attestation in the registry beside the image, so anyone who can
pull the image can verify it without needing access to the repository.
Verification:
gh attestation verify oci://ghcr.io/YOUR_ORG/YOUR_IMAGE:TAG -R YOUR_ORG/YOUR_REPOBoth approaches produce Sigstore-signed statements and interoperate. Use GitHub attestations when you build in Actions and want provenance with minimal setup; use Cosign when you need custom predicates, build elsewhere, or are already verifying with Cosign at admission.
Attaching the rest of the evidence
Section titled “Attaching the rest of the evidence”An image can carry several signed statements about it, all bound to the same digest.
{/* An SBOM, as an attestation */}cosign attest --yes \ --predicate sbom.spdx.json --type spdxjson \ ghcr.io/YOUR_ORG/YOUR_IMAGE@sha256:YOUR_DIGEST{/* Verify a specific predicate type, with an identity constraint */}cosign verify-attestation --type spdxjson \ --certificate-identity-regexp "^https://github.com/YOUR_ORG/YOUR_REPO/" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com \ ghcr.io/YOUR_ORG/YOUR_IMAGE@sha256:YOUR_DIGESTThe --type flag matters on both sides. An image with a provenance attestation, an SBOM attestation
and a scan-result attestation has three statements; verifying without specifying which one checks
whatever is present rather than what you meant.
Signing and scanning together
Section titled “Signing and scanning together”The two checks answer different questions and belong at different points, and getting the order wrong produces a pipeline that signs vulnerable images.
The sequence that works:
-
Build the image, pushing it and capturing the digest.
-
Scan it by digest. Fail the job on findings above your threshold.
-
Sign it, only if the scan passed.
-
Attest the SBOM and the scan result, so the evidence travels with the image.
-
Deploy, verifying the signature and, if your policy requires it, the scan attestation.
Signing before scanning inverts the meaning of the signature. A signed image that failed its scan is a signed vulnerable image, and every downstream verifier that treats a signature as a quality signal — which people do, whatever the documentation says — will accept it.
Attesting the scan result in step 4 is the piece that lets an admission policy assert on more than identity: this image was built by the release workflow and passed a scan at a stated severity threshold on a stated date. That is a materially stronger policy than either check alone, and it is available once both produce attestations bound to the same digest.
One caveat worth stating: a scan attestation is a point-in-time claim. An image scanned clean in March is not clean in September, because the advisory data moved rather than the image. A policy asserting on scan attestations should also assert on their age, or it will happily admit a year-old clean verdict.
Admission policy
Section titled “Admission policy”Verification in a deployment workflow covers the path you control. Admission control covers everything arriving.
apiVersion: policy.sigstore.dev/v1beta1kind: ClusterImagePolicymetadata: name: require-signed-application-imagesspec: images: - glob: "ghcr.io/YOUR_ORG/**" authorities: - keyless: url: https://fulcio.sigstore.dev identities: - issuer: https://token.actions.githubusercontent.com subjectRegExp: "^https://github.com/YOUR_ORG/YOUR_REPO/\\.github/workflows/release\\.yml@.*$"The subjectRegExp is the control. Two ways it commonly ends up too weak:
Matching the whole organisation. Any workflow in any repository can then produce an admissible image.
Matching any workflow in one repository. A workflow added in a branch could sign, if it can obtain an identity token.
Naming the release workflow specifically means an attacker must get code into that workflow’s path, which is a problem the repository controls in Repository Security are designed to make hard.
Where the signature is actually stored
Section titled “Where the signature is actually stored”Worth knowing, because it explains several confusing behaviours.
Cosign stores signatures and attestations in the registry, as additional artifacts alongside the image, named by a convention derived from the subject’s digest. A signed image is therefore not one object but several, related by digest.
Three practical consequences.
Registry garbage collection can delete them. A retention policy that removes untagged manifests can remove the signature while keeping the image, and the failure appears later as an unverifiable image nobody changed. Check your registry’s retention rules before relying on stored signatures.
Copying an image does not copy its signature. docker pull and docker push to move an image
between registries moves the image and leaves the signature behind. Use a tool that understands the
relationship — cosign copy or an OCI-aware copier — or the image arrives at its destination
unverifiable.
Registry support varies. Most modern registries handle this correctly. Some older or restricted ones reject the artifact types, and the symptom is a signing step that fails at push with an error about an unsupported media type.
The mirroring case is the one that bites in practice: an organisation that pulls public images through an internal mirror finds that verification stops working, because the mirror copied the images and not the signatures.
Verifying before deployment
Section titled “Verifying before deployment”The check has to exist somewhere that can refuse. In a deployment workflow:
- uses: sigstore/cosign-installer@v4
- name: Verify the image before deploying run: | cosign verify "ghcr.io/${REPO}@${DIGEST}" \ --certificate-identity-regexp "^https://github.com/${REPO}/\.github/workflows/release\.yml@" \ --certificate-oidc-issuer https://token.actions.githubusercontent.com env: REPO: ${{ github.repository }} DIGEST: ${{ inputs.digest }}
- name: Deploy run: ./deploy.sh "ghcr.io/${REPO}@${DIGEST}" env: REPO: ${{ github.repository }} DIGEST: ${{ inputs.digest }}Two properties of that arrangement matter more than the command.
The digest is an input, passed from the build or promotion step, rather than resolved from a tag inside the deployment. Resolving a tag here reintroduces exactly the mutability the digest was protecting against.
The verify step has no continue-on-error. A failed verification fails the job, and a failed job
does not deploy. That single omission is the difference between a control and a log line, and it is
the most common thing to get wrong.
When you cannot go keyless
Section titled “When you cannot go keyless”Keyless signing needs an OIDC identity and network access to Fulcio and Rekor. Two situations rule it out: air-gapped builds, and builds on a platform with no OIDC identity to present.
Cosign supports key-based signing for these cases:
{/* Generates a key pair; the private key is encrypted with a password */}cosign generate-key-pairEverything from GPG signing about key management then applies, plus the specifics:
The private key needs a home. A KMS, a hardware module, or a secret in the CI system. Cosign supports cloud KMS references, which is the best of these because the key material never reaches the runner.
Rotation is your problem. No expiry, no revocation mechanism, and consumers hold the public key.
Distribution is your problem. Every verifier needs the public key, and needs to have got it from somewhere they trust.
A leak is bad in a specific way. An attacker can sign images that verify. There is no transparency log to notice it in, which is the property keyless signing provides.
Use keys when you must. Prefer keyless when you can, and note that the reason is operational rather than cryptographic: the key-based version works fine right up until somebody has to rotate it.
Base images: the inputs you also do not verify
Section titled “Base images: the inputs you also do not verify”Signing what you publish is the producer half. The consumer half is your FROM line, and it is the
input with the widest reach into what you ship.
Pin base images by digest.
{/* A tag: whatever it points at when the build runs */}FROM node:22-alpine
{/* A digest: exactly these bytes, every time */}FROM node:22-alpine@sha256:YOUR_DIGEST_HEREThe first form means your build’s inputs change without any change to your repository. Two builds of the same commit, a month apart, produce different images — and the provenance faithfully records that you built from something you cannot reproduce.
The objection is that a pinned base image never receives security updates. That is true and it is an
argument for updating the pin deliberately rather than for not pinning: Dependabot’s docker
ecosystem opens a pull request when the base image moves, which turns a silent change into a reviewed
one. See Dependabot.
Verify base image signatures where they exist. A growing number of official and vendor images are signed. Verifying them in the build is a step most organisations have never added:
- name: Verify the base image before building run: | cosign verify "${BASE_IMAGE}" \ --certificate-identity-regexp "${EXPECTED_IDENTITY}" \ --certificate-oidc-issuer "${EXPECTED_ISSUER}" env: BASE_IMAGE: registry.example.com/base@sha256:YOUR_DIGEST EXPECTED_IDENTITY: ^https://github.com/VENDOR/REPO/ EXPECTED_ISSUER: https://token.actions.githubusercontent.comThe asymmetry from the cluster hub applies directly here: signing your images protects your consumers, and verifying your base images protects you. Organisations do the first because it is visible and skip the second because nobody asks for it.
Multi-architecture images
Section titled “Multi-architecture images”A multi-architecture image is an index pointing at one manifest per platform, each with its own digest.
Sign the index digest. Verifying the index covers the whole image as published, and it is the digest a deployment references.
Be aware that per-platform manifests have their own digests. A verifier resolving to a specific platform may end up checking a manifest rather than the index, and a policy written for one shape can fail confusingly on the other.
docker/build-push-action outputs the index digest for a multi-platform build, which is the value to
sign. Test verification on each platform you actually deploy to; the failure mode here is a policy
that passes on the architecture you tested and fails on the one you did not.
The threat model
Section titled “The threat model”Threat. A container image runs in production that is not the image your pipeline built and tested.
Attack surface. The registry and anything with push access to it. The tags your deployment references. The mirror or proxy in between. The deployment configuration that selects an image. And the verification policy, which fails open in mundane ways.
Impact. Arbitrary code running with your workload’s identity, network position and secrets, delivered by the mechanism everything legitimate arrives by.
Control. Sign the digest at build time with an identity the build cannot fabricate; deploy by digest; verify against a locally-specified identity; refuse on mismatch, at admission if possible.
Verification. Three tests. A legitimate image verifies. An image signed by a different identity fails. An unsigned image fails. Until you have seen the second and third produce a failure, you have tested that your pipeline works and not that your policy does.
The most likely real-world path is not a sophisticated registry compromise. It is a credential with push access to the registry — in a CI secret, on a developer machine, in a Kubernetes pull secret — being used to push a new image to an existing tag. That is why deploying by digest closes so much of this on its own, before any signing is involved.
Adopting it
Section titled “Adopting it”The order matters, because each step is only useful once the previous one holds.
-
Deploy by digest. Before any signing. This alone removes the tag-repointing path and it is a change to deployment manifests rather than to the build.
-
Sign in the build, keylessly, by digest. One installer step and one sign step.
-
Verify by hand, with identity flags, and then deliberately wrongly. Learn what the flags assert.
-
Publish the expected identity so consumers — including your own deployment pipeline — know what to assert.
-
Add verification to the deployment, failing on mismatch.
-
Add attestations: provenance, then SBOM, so the signature carries claims rather than only an identity.
-
Run admission policy in warn mode, and collect what would have been blocked. This is where the third-party image list comes from.
-
Enforce at admission, with the exceptions you discovered in step 7.
Step 1 is the highest value-to-effort step on the page and it involves no cryptography at all. Step 8 is the strongest control and takes the longest, mostly because of step 7.
Promotion between environments
Section titled “Promotion between environments”Most organisations move an image through environments, and the promotion step is where a digest-based chain most often breaks.
The pattern that preserves it:
Build once, in the release workflow. One image, one digest, one set of signatures and attestations.
Promote by digest. Staging deploys image@sha256:abc…; production deploys the same
image@sha256:abc…. Add a tag for humans if you like, but the deployment references the digest.
Verify at each promotion, so the artifact entering production is confirmed to be the one that was built, scanned and signed.
Copy signatures if you copy images. Promotion that moves the image between registries — a public build registry to an internal production one, say — must move the signatures and attestations too, or verification at the destination fails on an image that is perfectly legitimate.
The anti-pattern is rebuilding per environment. Two builds of the same commit produce two digests, two signatures and two provenance records; everything you tested in staging applies to a different image, and no amount of signing repairs that, because both signatures are accurate about different things.
Common mistakes
Section titled “Common mistakes”Signing a tag. The signature describes whatever it pointed at then, and the tag can move.
Deploying by tag. You deploy whatever it points at now, which may not be what you signed.
Verifying without identity constraints. cosign verify with no --certificate-identity passes for
an image signed by anybody, and prints a success message while doing so.
An over-broad identity regexp. Organisation-wide matching means any workflow can produce an admissible image.
Omitting id-token: write. No keyless signing, and a confusing error.
Enforcing at admission on day one. Third-party images are blocked immediately, production stops, and the policy is disabled by lunchtime with nobody keen to try again.
Signing and never verifying. The most common outcome by a wide margin. Signing is preparation; verification is the control, and only one of the two is visible in a workflow file.
Believing a signature implies the image is safe. It attests to origin. Scan separately, and scan before signing so a failed scan cannot produce a signed image.
Losing signatures when copying between registries. A plain pull-and-push moves the image and leaves the signature, and the destination image becomes unverifiable through no fault of anyone’s.
Mental model
Section titled “Mental model”A signature binds an identity to a digest. Everything that makes container signing work follows from that: sign digests, deploy digests, and treat tags as labels for humans rather than as identifiers for machines.
What you learned
Section titled “What you learned”- A tag is a mutable pointer; a digest is the content and cannot be repointed
- Sign the digest from the build step’s output, never the tag it pushed
id-token: writeis what enables keyless signing in a workflowpush-to-registry: truestores attestations beside the image so consumers can verify without repository access- Verification without an identity constraint passes for an image signed by anybody
--typematters when an image carries several attestations- Admission policy covers everything arriving; pipeline verification covers your path only
- The hard part of admission policy is the third-party images, not your own
- Key-based signing remains available for air-gapped builds, with all the key management that implies
- Sign the index digest for multi-architecture images, and test verification per platform
Exercise
Section titled “Exercise”Use a disposable public repository and the GitHub Container Registry.
-
Build and push an image. Capture the digest from the build step’s output.
-
Sign it by digest with Cosign, keyless. Verify with identity flags. Predict: what does the output show about the certificate?
-
Verify without identity flags. Predict: does it pass? What has it established?
-
Sign the same image by tag instead. Then build a different image and push it to the same tag. Verify by tag. Predict: what happens, and why?
-
Attach an SBOM as an attestation and verify it with
--type. Then verify without--type. Compare. -
Try the GitHub attestation route on the same image and verify with
gh attestation verify. Predict: can Cosign verify a GitHub-generated attestation? -
Write an admission policy
subjectRegExpfor your release workflow. Then list every third-party image you would need an exception for. Predict: is that list longer than you expected? -
Delete the repository and the packages.