Lab: Signing and Verifying Container Images with Cosign in GitHub Actions

Overview

Every container image your CI/CD pipeline produces should be cryptographically signed before it reaches any environment. Unsigned images are a blind spot — you have no proof they came from your pipeline, no guarantee they weren’t tampered with in transit, and no policy hook to block rogue deployments.

In this hands-on lab you will:

  • Sign a container image locally using a Cosign key pair.
  • Set up keyless signing in GitHub Actions using Sigstore’s Fulcio and Rekor infrastructure.
  • Verify signatures locally with identity-based certificate checks.
  • Enforce signature verification at admission time in Kubernetes using Kyverno.
  • Attach and verify an SBOM attestation with Cosign and Syft.

By the end you will have a complete GitHub Actions workflow that builds, pushes, signs, and attests every image — and a Kubernetes policy that rejects anything unsigned.

Prerequisites

Before starting, make sure you have the following ready:

  • GitHub account with permission to create repositories and enable GitHub Actions.
  • Container registry account — this lab uses GitHub Container Registry (GHCR), but Docker Hub works too.
  • Docker installed and running locally.
  • Cosign CLI installed locally:
# macOS (Homebrew)
brew install cosign

# Or install from source with Go
go install github.com/sigstore/cosign/v2/cmd/cosign@latest

# Verify installation
cosign version
  • kubectl and Helm installed (for the Kyverno exercise).
  • Syft installed (for the SBOM exercise):
brew install syft

You will also need a simple application to containerize. Here is a minimal Go application and its Dockerfile that we will use throughout the lab.

main.go

package main

import (
	"fmt"
	"net/http"
)

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		fmt.Fprintf(w, "Hello from a signed container!")
	})
	http.ListenAndServe(":8080", nil)
}

Dockerfile

FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY main.go .
RUN go build -o server main.go

FROM alpine:3.19
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]

Environment Setup

Start by creating a test repository and pushing the application code.

Step 1 — Create the repository

# Create a new directory and initialize a Git repo
mkdir cosign-lab && cd cosign-lab
git init

# Create the Go application and Dockerfile from the prerequisites above
# Then push to GitHub
git add .
git commit -m "Initial commit: simple Go app"
gh repo create cosign-lab --public --source=. --push

Step 2 — Create the unsigned workflow

Before adding signing, create a baseline workflow that only builds and pushes the image. This gives you something to compare against later.

Create .github/workflows/build.yml:

name: Build and Push (Unsigned)

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

Commit and push this workflow. Tag a release to trigger it:

git add .
git commit -m "Add unsigned build workflow"
git push origin main
git tag v0.1.0
git push origin v0.1.0

Once the workflow completes, your image sits in GHCR — but it carries no cryptographic signature. Anyone with write access to the registry could replace it, and nothing downstream would notice.

Exercise 1: Local Signing with a Key Pair

Before moving to keyless signing in CI, it helps to understand the fundamentals by signing an image locally with an explicit key pair.

Step 1 — Generate a Cosign key pair

cosign generate-key-pair

This creates two files in your current directory:

  • cosign.key — the private key (encrypted with a passphrase you choose).
  • cosign.pub — the public key you distribute to verifiers.

Step 2 — Build, push, and sign the image

# Build the image
docker build -t ghcr.io/<your-username>/cosign-lab:v1 .

# Push to GHCR
docker push ghcr.io/<your-username>/cosign-lab:v1

# Sign the image with your private key
cosign sign --key cosign.key ghcr.io/<your-username>/cosign-lab:v1

Replace <your-username> with your GitHub username. Cosign will prompt for the passphrase you set during key generation.

Step 3 — Verify the signature

cosign verify --key cosign.pub ghcr.io/<your-username>/cosign-lab:v1

You should see output similar to:

Verification for ghcr.io/<your-username>/cosign-lab:v1 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - The signatures were verified against the specified public key

[{"critical":{"identity":{"docker-reference":"ghcr.io/<your-username>/cosign-lab"},"image":{"docker-manifest-digest":"sha256:abc123..."},"type":"cosign container image signature"},"optional":null}]

Where is the signature stored?

Cosign stores signatures as OCI artifacts in the same registry, alongside the image. For an image tagged sha256:abc123, Cosign pushes the signature to a tag derived from that digest — sha256-abc123.sig. This means:

  • No separate signature storage infrastructure is needed.
  • Signatures travel with the image when you mirror or replicate registries.
  • Registry access controls apply to signatures the same way they apply to images.

Key-pair signing works, but it introduces a key management burden: you must protect the private key, rotate it periodically, and distribute the public key to every verifier. In the next exercise, we eliminate that burden entirely with keyless signing.

Exercise 2: Keyless Signing in GitHub Actions

Keyless signing removes the need to generate, store, or rotate signing keys. Instead, it relies on short-lived certificates issued by Fulcio and recorded in the Rekor transparency log.

How keyless signing works

  1. OIDC token — GitHub Actions mints an OIDC identity token that proves the workflow’s identity (repository, workflow file, ref, and more).
  2. Fulcio certificate — Cosign sends that OIDC token to Fulcio, which issues a short-lived X.509 signing certificate bound to the workflow identity.
  3. Signing — Cosign signs the image digest with the ephemeral private key that corresponds to the Fulcio certificate.
  4. Rekor transparency log — The signature and certificate are recorded in Rekor so anyone can audit when and by whom an image was signed.
  5. Key disposal — The ephemeral private key is discarded immediately. Verification uses the certificate and the Rekor entry, not a long-lived public key.

Step 1 — Create the signing workflow

Create .github/workflows/sign.yml:

name: Build, Push, and Sign

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-and-sign:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write   # Required for keyless signing via OIDC

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

Key details in this workflow

  • id-token: write — this permission lets the runner request an OIDC token from GitHub, which Fulcio uses to issue the signing certificate.
  • packages: write — required to push the image and its signature to GHCR.
  • cosign sign --yes — the --yes flag confirms non-interactive mode (no prompt for keyless consent). No --key flag means Cosign automatically uses keyless signing.
  • We sign by digest (@sha256:...) rather than by tag to ensure we sign the exact image we just built.

Step 2 — Push and trigger the workflow

git add .github/workflows/sign.yml
git commit -m "Add keyless signing workflow"
git push origin main
git tag v1.0.0
git push origin v1.0.0

Step 3 — Review the Actions logs

In the “Sign the image” step you will see output similar to:

Generating ephemeral keys...
Retrieving signed certificate...

        The sigstore community wants to hear from you! Connect with us at
        https://links.sigstore.dev/slack-invite

Successfully verified SCT...
tlog entry created with index: 45678901
Pushing signature to: ghcr.io/<your-username>/cosign-lab:sha256-a1b2c3d4.sig

The image is now signed with a certificate that cryptographically binds it to your GitHub Actions workflow identity. The signature and certificate are permanently recorded in the Rekor transparency log.

Exercise 3: Verifying Signatures Locally

Verifying a keyless-signed image requires two pieces of information: the certificate identity (who signed it) and the OIDC issuer (who vouched for that identity).

Step 1 — Verify the signed image

cosign verify \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Successful output:

Verification for ghcr.io/<your-username>/cosign-lab:v1.0.0 --
The following checks were performed on each of these signatures:
  - The cosign claims were validated
  - Existence of the claims in the transparency log was verified offline
  - The code-signing certificate was verified using trusted certificate authority
  - The signatures were verified against the specified public key
  - The signature was verified against a valid Fulcio certificate

[{"critical":{"identity":{"docker-reference":"ghcr.io/<your-username>/cosign-lab"},"image":{"docker-manifest-digest":"sha256:a1b2c3d4..."},"type":"cosign container image signature"},"optional":{...}}]

Step 2 — Verify with an incorrect identity (expect failure)

cosign verify \
  --certificate-identity "https://github.com/attacker/malicious-repo/.github/workflows/build.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Output:

Error: no matching signatures:
none of the expected identities matched what was in the certificate

This confirms that signatures are identity-bound. Even if someone manages to push a signature, it won’t pass verification unless it was signed by the exact workflow you specify.

Step 3 — Verify an unsigned image (expect failure)

cosign verify \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v0.1.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v0.1.0

Output:

Error: no matching signatures
no signatures found for image

The v0.1.0 image was built with the unsigned workflow from the Environment Setup section, so no signature exists.

Understanding the certificate fields

When you verify a keyless signature, Cosign checks several fields embedded in the Fulcio certificate:

  • Issuer (certificate-oidc-issuer) — the OIDC provider that authenticated the signer. For GitHub Actions this is always https://token.actions.githubusercontent.com.
  • Subject / Identity (certificate-identity) — the full workflow reference including the repository, workflow file path, and Git ref. This ties the signature to a specific workflow at a specific commit or tag.
  • GitHub Workflow Extensions — the certificate also contains custom OID extensions for the repository, workflow SHA, trigger event, and runner environment. These allow fine-grained verification policies.

You can also use regex matching for more flexible policies:

cosign verify \
  --certificate-identity-regexp "https://github.com/<your-username>/cosign-lab/.*" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

This is useful when you want to accept signatures from any workflow in a repository, or from any tag.

Exercise 4: Verifying in Kubernetes with Kyverno

Local verification is useful for debugging, but production clusters need automated enforcement. Kyverno is a Kubernetes admission controller that can verify Cosign signatures on every pod admission request.

Step 1 — Install Kyverno

helm repo add kyverno https://kyverno.github.io/kyverno/
helm repo update
helm install kyverno kyverno/kyverno -n kyverno --create-namespace

Wait for the Kyverno pods to become ready:

kubectl wait --for=condition=ready pod -l app.kubernetes.io/instance=kyverno -n kyverno --timeout=120s

Step 2 — Create the image verification policy

Save the following as require-signed-images.yml:

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: require-cosign-signature
spec:
  validationFailureAction: Enforce
  background: false
  rules:
    - name: verify-cosign-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/<your-username>/cosign-lab:*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: "https://rekor.sigstore.dev"

Apply the policy:

kubectl apply -f require-signed-images.yml

Step 3 — Test with a signed image (should succeed)

kubectl run signed-app \
  --image=ghcr.io/<your-username>/cosign-lab:v1.0.0 \
  --restart=Never

Expected output:

pod/signed-app created

Step 4 — Test with an unsigned image (should fail)

kubectl run unsigned-app \
  --image=ghcr.io/<your-username>/cosign-lab:v0.1.0 \
  --restart=Never

Expected output:

Error from server: admission webhook "mutate.kyverno.svc-fail" denied the request:
resource Pod/default/unsigned-app was blocked due to the following policies:

require-cosign-signature:
  verify-cosign-signature: |
    image verification failed for ghcr.io/<your-username>/cosign-lab:v0.1.0:
    no matching signatures found

This is exactly the enforcement loop you want: only images signed by your trusted GitHub Actions workflow are allowed into the cluster.

Exercise 5: Attaching an SBOM

A signature proves who built the image. An SBOM attestation proves what is inside it. Combining both gives you a complete chain of trust: identity, integrity, and content transparency.

Step 1 — Generate the SBOM with Syft

syft ghcr.io/<your-username>/cosign-lab:v1.0.0 -o spdx-json > sbom.spdx.json

This scans the image layers and produces an SPDX-formatted JSON document listing every package, library, and dependency inside the image.

Step 2 — Attach the SBOM as an attestation

cosign attest \
  --predicate sbom.spdx.json \
  --type spdxjson \
  --yes \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Like keyless signing, this uses OIDC-based identity when run in GitHub Actions or requests interactive authentication when run locally. The attestation is stored as an OCI artifact alongside the image.

Step 3 — Verify the attestation

cosign verify-attestation \
  --type spdxjson \
  --certificate-identity "https://github.com/<your-username>/cosign-lab/.github/workflows/sign.yml@refs/tags/v1.0.0" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/<your-username>/cosign-lab:v1.0.0

Successful verification confirms that the SBOM was generated and attached by your trusted workflow, and that it has not been tampered with since.

The Complete Signing Pipeline

Here is the final workflow that combines everything: build, push, sign, generate an SBOM, and attest it. Save this as .github/workflows/sign-and-attest.yml:

name: Build, Sign, and Attest

on:
  push:
    tags:
      - 'v*'

env:
  REGISTRY: ghcr.io
  IMAGE_NAME: ${{ github.repository }}

jobs:
  build-sign-attest:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write

    steps:
      - name: Checkout code
        uses: actions/checkout@v4

      - name: Install Cosign
        uses: sigstore/cosign-installer@v3

      - name: Install Syft
        uses: anchore/sbom-action/download-syft@v0

      - name: Log in to GHCR
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}

      - name: Extract metadata
        id: meta
        uses: docker/metadata-action@v5
        with:
          images: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}

      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ steps.meta.outputs.tags }}
          labels: ${{ steps.meta.outputs.labels }}

      - name: Sign the image
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Generate SBOM
        run: |
          syft ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} \
            -o spdx-json > sbom.spdx.json

      - name: Attest SBOM
        run: |
          cosign attest --yes \
            --predicate sbom.spdx.json \
            --type spdxjson \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Verify signature
        run: |
          cosign verify \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

      - name: Verify SBOM attestation
        run: |
          cosign verify-attestation \
            --type spdxjson \
            --certificate-identity-regexp "https://github.com/${{ github.repository }}/.*" \
            --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}

This workflow gives you a complete chain of trust for every tagged release: the image is signed, its contents are documented in an SBOM, and the SBOM is cryptographically attested — all without managing a single long-lived key.

Cleanup

When you are finished with the lab, clean up the resources you created.

Delete test images from GHCR

Navigate to https://github.com/<your-username>?tab=packages and delete the cosign-lab package, or use the GitHub CLI:

# List package versions
gh api user/packages/container/cosign-lab/versions | jq '.[].id'

# Delete each version
gh api --method DELETE user/packages/container/cosign-lab/versions/<version-id>

Remove Kyverno

kubectl delete clusterpolicy require-cosign-signature
helm uninstall kyverno -n kyverno
kubectl delete namespace kyverno

Delete the test repository

gh repo delete <your-username>/cosign-lab --yes

Remove local files

cd .. && rm -rf cosign-lab
rm -f cosign.key cosign.pub

Key Takeaways

  • Keyless signing eliminates key management. By using OIDC identity tokens from GitHub Actions and short-lived Fulcio certificates, you avoid the operational burden of generating, storing, rotating, and distributing signing keys.
  • Signatures are identity-bound, not key-bound. Verification checks who signed the image (which workflow, in which repository, at which ref) rather than which key was used. This makes policies more intuitive and auditable.
  • The Rekor transparency log provides a tamper-evident audit trail. Every signature is recorded publicly, so you can prove when an image was signed and detect any attempt to backdate or remove signatures.
  • Admission controllers enforce signing policies at deploy time. Kyverno (or alternatives like Connaisseur or Sigstore Policy Controller) ensures that unsigned or incorrectly signed images never run in your cluster.
  • SBOM attestations extend the chain of trust. Signing proves who built the image; attaching a signed SBOM proves what is inside it. Together, they provide complete provenance from source to runtime.
  • Sign by digest, not by tag. Tags are mutable — someone can move a tag to a different image. Digests are immutable content addresses, so signing by digest guarantees you signed exactly the image you built.

Next Steps

Continue building your supply chain security knowledge with these related guides: