Skip to content

Signing Container Images

Lesson 7 of 10Advanced14 min readGit Security & DevSecOps · Supply Chain SecurityVerified: sigstore/cosign-installer v4, actions/attest v4, docker/build-push-action v7, September 2026

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.

Sign the digest, never the tag.

Terminal window
{/* Keyless, in CI, using the workflow's OIDC identity */}
cosign sign --yes ghcr.io/YOUR_ORG/YOUR_IMAGE@sha256:YOUR_DIGEST

Verify with an identity constraint, or you have verified nothing useful:

Terminal window
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.com

Enforce at admission, so the check covers everything reaching the cluster rather than only what your pipeline sends.

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.

Terminal window
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:

PracticeConsequence
Signing a tagThe signature describes whatever the tag pointed at when you signed
Deploying a tagYou deploy whatever it points at now
Promoting a tagStaging and production can silently diverge
Deploying a digestYou 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.

.github/workflows/release.yml
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.

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: true

push-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:

Terminal window
gh attestation verify oci://ghcr.io/YOUR_ORG/YOUR_IMAGE:TAG -R YOUR_ORG/YOUR_REPO

Both 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.

An image can carry several signed statements about it, all bound to the same digest.

Terminal window
{/* An SBOM, as an attestation */}
cosign attest --yes \
--predicate sbom.spdx.json --type spdxjson \
ghcr.io/YOUR_ORG/YOUR_IMAGE@sha256:YOUR_DIGEST
Terminal window
{/* 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_DIGEST

The --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.

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:

  1. Build the image, pushing it and capturing the digest.

  2. Scan it by digest. Fail the job on findings above your threshold.

  3. Sign it, only if the scan passed.

  4. Attest the SBOM and the scan result, so the evidence travels with the image.

  5. 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.

Verification in a deployment workflow covers the path you control. Admission control covers everything arriving.

ClusterImagePolicy
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
name: require-signed-application-images
spec:
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.

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.

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.

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:

Terminal window
{/* Generates a key pair; the private key is encrypted with a password */}
cosign generate-key-pair

Everything 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_HERE

The 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.com

The 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.

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.

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.

The order matters, because each step is only useful once the previous one holds.

  1. 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.

  2. Sign in the build, keylessly, by digest. One installer step and one sign step.

  3. Verify by hand, with identity flags, and then deliberately wrongly. Learn what the flags assert.

  4. Publish the expected identity so consumers — including your own deployment pipeline — know what to assert.

  5. Add verification to the deployment, failing on mismatch.

  6. Add attestations: provenance, then SBOM, so the signature carries claims rather than only an identity.

  7. Run admission policy in warn mode, and collect what would have been blocked. This is where the third-party image list comes from.

  8. 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.

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.

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.

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.

  • 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: write is what enables keyless signing in a workflow
  • push-to-registry: true stores attestations beside the image so consumers can verify without repository access
  • Verification without an identity constraint passes for an image signed by anybody
  • --type matters 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

Use a disposable public repository and the GitHub Container Registry.

  1. Build and push an image. Capture the digest from the build step’s output.

  2. Sign it by digest with Cosign, keyless. Verify with identity flags. Predict: what does the output show about the certificate?

  3. Verify without identity flags. Predict: does it pass? What has it established?

  4. 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?

  5. Attach an SBOM as an attestation and verify it with --type. Then verify without --type. Compare.

  6. Try the GitHub attestation route on the same image and verify with gh attestation verify. Predict: can Cosign verify a GitHub-generated attestation?

  7. Write an admission policy subjectRegExp for your release workflow. Then list every third-party image you would need an exception for. Predict: is that list longer than you expected?

  8. Delete the repository and the packages.

GitHub Actions Security ChecklistToken permissions, fork pull requests, script injection and supply chain — with the attack each item prevents.

The repository security templates — secrets management and least-privilege token guides — are in the Professional Toolkit.