All articles
Tutorials

Sign Container Images with Cosign in GitHub Actions

Learn how to sign container images with Cosign in GitHub Actions using keyless OIDC identity, then verify the signature before a Kubernetes deployment can proceed.

Sign Container Images with Cosign in GitHub Actions cover
7 min read

TL;DR

  • A mutable container tag such as latest identifies no immutable build and does not prove who published it.
  • Cosign 2.4.1 can 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.

ControlAnswersDoes not answer
Image tagWhat friendly name did the publisher use?Whether the tag moved or who built it
Image digestWhich immutable bytes are referenced?Whether the publisher is trusted
Registry authenticationWho can push or pull?Whether this digest came from the approved workflow
Cosign signatureWhich 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:

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:

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

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

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

kubernetes/deployment.yaml
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: 8080

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

  1. 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.
  2. 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.
  3. Confirm the OIDC issuer is https://token.actions.githubusercontent.com. Do not remove issuer checking to make a verification command pass.
  4. Confirm the registry preserves Cosign signatures and referrers. Some proxy or mirroring configurations copy the image manifest but omit related signature artifacts.
  5. 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: write can 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.

Frequently asked questions

Share𝕏

Writer

  • Ilyas Rufai

    Technical content writer and DevSecOps specialist focused on cloud-native security and developer experience

Need help with your technical content?

We help B2B SaaS teams turn complex products into clear documentation and content that developers actually use.

Book a call
Sign Container Images with Cosign in GitHub Actions | Reclear