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
- OIDC token — GitHub Actions mints an OIDC identity token that proves the workflow’s identity (repository, workflow file, ref, and more).
- Fulcio certificate — Cosign sends that OIDC token to Fulcio, which issues a short-lived X.509 signing certificate bound to the workflow identity.
- Signing — Cosign signs the image digest with the ephemeral private key that corresponds to the Fulcio certificate.
- Rekor transparency log — The signature and certificate are recorded in Rekor so anyone can audit when and by whom an image was signed.
- 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--yesflag confirms non-interactive mode (no prompt for keyless consent). No--keyflag 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 alwayshttps://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:
- Signing and Verifying Container Images with Sigstore and Cosign — a comprehensive guide covering Cosign’s architecture, advanced verification patterns, and integration with different registries and CI systems.
- Artifact Provenance and Attestations: From SLSA to in-toto — understand the broader provenance ecosystem including SLSA levels, in-toto layouts, and how attestations fit into a complete supply chain security strategy.