TL;DR
- A mutable container tag such as
latestidentifies no immutable build and does not prove who published it. - Cosign
2.4.1can sign an image keylessly in GitHub Actions using the workflow's OIDC identity instead of a stored private key. - Verify the image digest, certificate identity, and OIDC issuer before deployment, not only after the image reaches a registry.
- Signing establishes provenance and integrity; it does not replace vulnerability scanning, least privilege, or runtime admission policy.
A container registry can tell you that an image exists, but a tag such as payments:latest does not tell you which build produced it or whether the tag moved after review. If an attacker can push to the repository or registry, the deployment may pull a different image while the manifest stays unchanged.
This article signs a container image with Cosign 2.4.1 in GitHub Actions using keyless OIDC identity, then verifies the signature against the exact GitHub repository and workflow issuer before deployment.
Note
What you need: Docker 27.3.1 or later, Cosign 2.4.1, a GitHub repository, and an OCI-compatible registry such as GitHub Container Registry. The signing job uses GitHub-hosted runners and does not store a Cosign private key.
Why unsigned container images reach production
A digest protects integrity only after someone has decided which digest is trusted. A registry login protects who can push, but it does not give a deployment system enough information to distinguish an approved build from another image the same account can publish.
The OCI image specification defines image manifests and digests. Sigstore's Cosign documentation adds a verifiable signature and certificate identity to that artifact.
| Control | Answers | Does not answer |
|---|---|---|
| Image tag | What friendly name did the publisher use? | Whether the tag moved or who built it |
| Image digest | Which immutable bytes are referenced? | Whether the publisher is trusted |
| Registry authentication | Who can push or pull? | Whether this digest came from the approved workflow |
| Cosign signature | Which identity signed this digest? | Whether the image has no vulnerabilities |
The failure is treating a registry push as an attestation. The fix is to make signing part of the build and make verification a required deployment input.
Build an image with an immutable reference
Start with a small image so the tutorial has one artifact and one digest to track. The following container prints a response and does not require an application framework.
Create app/server.js:
const http = require("node:http")
const server = http.createServer((_request, response) => {
response.writeHead(200, { "content-type": "text/plain" })
response.end("signed image is running\n")
})
server.listen(8080, "0.0.0.0")Create Dockerfile:
FROM node:22.14.0-alpine3.21
WORKDIR /app
COPY app/server.js ./server.js
USER node
EXPOSE 8080
CMD ["node", "server.js"]Build and run the image locally to verify the application before adding signing:
docker build --tag ghcr.io/OWNER/secure-demo:local .
docker run --rm --publish 8080:8080 ghcr.io/OWNER/secure-demo:localIn another terminal, curl http://localhost:8080 should return signed image is running. Replace OWNER with the GitHub account or organization that owns the repository before using the registry reference in a workflow.
Sign the image with Cosign in GitHub Actions
Keyless signing needs two GitHub Actions permissions: id-token: write so the runner can request an OIDC token, and packages: write so it can publish the image and signature to GitHub Container Registry. Keep those permissions at the job or workflow boundary where they are needed.
Create .github/workflows/build-sign-push.yml:
name: Build and sign image
on:
push:
branches: [main]
tags: ["v*.*.*"]
permissions:
contents: read
id-token: write
packages: write
jobs:
build-sign-push:
runs-on: ubuntu-24.04
env:
IMAGE: ghcr.io/${{ github.repository }}
steps:
- name: Check out repository
uses: actions/checkout@v4
- name: Log in to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ${{ env.IMAGE }}:${{ github.sha }}
- name: Install Cosign
uses: sigstore/cosign-installer@v3.7.0
with:
cosign-release: "v2.4.1"
- name: Sign image by digest
env:
COSIGN_EXPERIMENTAL: "1"
run: |
digest=$(docker buildx imagetools inspect "$IMAGE:${GITHUB_SHA}" --format '{{json .Manifest.Digest}}' | tr -d '"')
cosign sign --yes "$IMAGE@$digest"
echo "IMAGE_DIGEST=$IMAGE@$digest" >> "$GITHUB_ENV"
- name: Verify the signature
env:
COSIGN_EXPERIMENTAL: "1"
run: |
cosign verify \
--certificate-identity "https://github.com/${GITHUB_REPOSITORY}/.github/workflows/build-sign-push.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$IMAGE_DIGEST"The workflow signs the digest, not the mutable tag. The certificate identity binds the signature to this repository's workflow on the main branch. Keep the workflow path and branch in the verification command aligned with your actual publishing workflow.
The first run should publish an image tagged with the commit SHA, create a signature in the registry's OCI referrers, and finish with Cosign verification output. The job does not read a Cosign private key because the signing certificate is issued for the workflow's short-lived identity.
How to verify the signature before deployment
A deployment should consume a digest and verify its provenance in the same pipeline. Do not verify a tag and deploy a tag separately because the tag can move between those operations.
Set the image digest from the successful build job or release metadata, then run this command in a job that has no registry write permission:
export IMAGE_DIGEST="ghcr.io/OWNER/secure-demo@sha256:REPLACE_WITH_BUILD_DIGEST"
cosign verify \
--certificate-identity "https://github.com/OWNER/REPOSITORY/.github/workflows/build-sign-push.yml@refs/heads/main" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
"$IMAGE_DIGEST"A successful result includes the certificate subject and the signed digest. A changed digest, a different repository, a pull request workflow, or a different branch should make verification fail.
For Kubernetes, put the verified digest in the workload manifest rather than the tag:
apiVersion: apps/v1
kind: Deployment
metadata:
name: signed-demo
spec:
selector:
matchLabels:
app: signed-demo
template:
metadata:
labels:
app: signed-demo
spec:
containers:
- name: signed-demo
image: ghcr.io/OWNER/secure-demo@sha256:REPLACE_WITH_BUILD_DIGEST
ports:
- containerPort: 8080This manifest records which bytes to run, but it does not perform admission-time verification by itself. For cluster-wide enforcement, use a policy engine such as Kyverno's image verification policy or Sigstore Policy Controller.
How to fix common verification failures
Signature verification errors are usually identity or artifact-reference errors. Diagnose them in this order.
- Confirm that the digest is the digest that the signing job printed. Rebuilding the same source can produce a different digest because the build context or base image changed.
- Confirm the certificate identity includes the correct owner, repository, workflow path, and branch. A signature from a feature branch should not satisfy a production policy intended for
main. - Confirm the OIDC issuer is
https://token.actions.githubusercontent.com. Do not remove issuer checking to make a verification command pass. - Confirm the registry preserves Cosign signatures and referrers. Some proxy or mirroring configurations copy the image manifest but omit related signature artifacts.
- Keep vulnerability scanning separate. CrashLoopBackOff troubleshooting helps diagnose runtime failures, but it cannot establish image provenance before scheduling.
If your workflow uses reusable workflows, verify the certificate identity emitted by the actual signing workflow and document that identity as part of the deployment policy.
When this approach breaks down
Keyless signing removes private-key storage, but it does not make the build pipeline trustworthy by itself.
- A compromised workflow with
id-token: writecan sign malicious code as the repository. Protect the workflow, pin third-party actions, require review for workflow changes, and restrict who can publish releases. - A valid signature does not prove that the image is vulnerability-free. Run a scanner such as Trivy and define a separate severity policy for release blocking.
- Registry mirroring, garbage collection, or unsupported referrer handling can separate signatures from images. Test the complete path from build registry to deployment registry.
- Certificate identity checks are only as specific as their patterns. A broad identity match can accept signatures from unintended branches or workflows.
Treat the signed digest as one release artifact alongside the vulnerability report, software bill of materials, and deployment approval.

