Skip to content
Back to blog Container Image Signing with Cosign - A Practical Guide

Container Image Signing with Cosign - A Practical Guide

SecurityDevOps

Container Image Signing with Cosign - A Practical Guide

How do you know the container image you’re about to deploy is the one your CI/CD built? Docker tags are mutable - anyone with registry write access can push a new image to myapp:latest. Digests help, but they don’t prove who built the image.

Cosign solves this by signing container images with cryptographic signatures. And with Sigstore’s keyless signing, you don’t even need to manage keys.

This post is a practical, hands-on guide to signing images with Cosign and enforcing signatures in Kubernetes.

TL;DR

Code Repository: All code from this post is available at github.com/moabukar/blog-code/container-image-signing-cosign

  • Cosign signs container images using OCI artifacts
  • Keyless signing uses OIDC identity (no keys to manage)
  • Signatures are stored alongside images in your registry
  • Kubernetes admission controllers can enforce signature verification
  • Start signing today - it’s free and adds minutes to your pipeline

Code Repository: All code from this post is available at github.com/moabukar/blog-code/container-image-signing-cosign


Installing Cosign

# macOS
brew install cosign

# Linux (latest release)
COSIGN_VERSION=$(curl -s https://api.github.com/repos/sigstore/cosign/releases/latest | jq -r .tag_name)
curl -LO "https://github.com/sigstore/cosign/releases/download/${COSIGN_VERSION}/cosign-linux-amd64"
chmod +x cosign-linux-amd64
sudo mv cosign-linux-amd64 /usr/local/bin/cosign

# Verify installation
cosign version

Keyless signing uses your identity (GitHub, Google, Microsoft) instead of managing private keys.

How It Works

1. You authenticate with an OIDC provider (GitHub, Google)
2. Fulcio (Sigstore's CA) verifies your identity
3. Fulcio issues a short-lived certificate (10 minutes)
4. You sign the image with this certificate
5. Signature + certificate are logged to Rekor (transparency log)
6. Verifiers can prove who signed what, and when

Sign an Image

# Build and push your image first
docker build -t ghcr.io/myorg/myapp:v1.0.0 .
docker push ghcr.io/myorg/myapp:v1.0.0

# Sign with keyless (opens browser for auth)
cosign sign --yes ghcr.io/myorg/myapp:v1.0.0

# Output:
# Generating ephemeral keys...
# Retrieving signed certificate...
# tlog entry created with index: 12345678
# Pushing signature to: ghcr.io/myorg/myapp

The --yes flag skips confirmation prompts (required for CI).

Verify a Signature

# Verify signature exists and matches identity
cosign verify \
  --certificate-identity=yourname@company.com \
  --certificate-oidc-issuer=https://accounts.google.com \
  ghcr.io/myorg/myapp:v1.0.0

# For GitHub Actions identity
cosign verify \
  --certificate-identity-regexp=https://github.com/myorg/.* \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  ghcr.io/myorg/myapp:v1.0.0

Key-Based Signing

If you need offline signing or can’t use OIDC, use key-based signing.

Generate Keys

# Generate a keypair (will prompt for password)
cosign generate-key-pair

# Output files:
# - cosign.key (private key - keep secret!)
# - cosign.pub (public key - distribute freely)

# Or generate without password (for CI)
COSIGN_PASSWORD="" cosign generate-key-pair

Sign with Key

# Sign image with private key
cosign sign --key cosign.key ghcr.io/myorg/myapp:v1.0.0

# Verify with public key
cosign verify --key cosign.pub ghcr.io/myorg/myapp:v1.0.0

Store Keys Securely

# Generate keys directly in a KMS
cosign generate-key-pair --kms awskms:///alias/cosign-key

# Sign using KMS key (no local key file)
cosign sign --key awskms:///alias/cosign-key ghcr.io/myorg/myapp:v1.0.0

# Supported KMS providers:
# - AWS KMS: awskms://
# - GCP KMS: gcpkms://
# - Azure Key Vault: azurekms://
# - HashiCorp Vault: hashivault://

CI/CD Integration

GitHub Actions (Keyless)

name: Build, Sign, Push
on:
  push:
    branches: [main]
    tags: ['v*']

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

jobs:
  build:
    runs-on: ubuntu-latest
    permissions:
      contents: read
      packages: write
      id-token: write  # Required for keyless signing
    
    steps:
      - uses: actions/checkout@v4
      
      - name: Log in to registry
        uses: docker/login-action@v3
        with:
          registry: ${{ env.REGISTRY }}
          username: ${{ github.actor }}
          password: ${{ secrets.GITHUB_TOKEN }}
      
      - name: Build and push
        id: build
        uses: docker/build-push-action@v5
        with:
          context: .
          push: true
          tags: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
      
      - name: Install Cosign
        uses: sigstore/cosign-installer@v3
      
      - name: Sign image
        env:
          DIGEST: ${{ steps.build.outputs.digest }}
        run: |
          cosign sign --yes \
            ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${DIGEST}

GitLab CI (Keyless)

# .gitlab-ci.yml
build-sign:
  image: docker:24
  services:
    - docker:24-dind
  variables:
    DOCKER_TLS_CERTDIR: "/certs"
  id_tokens:
    SIGSTORE_ID_TOKEN:
      aud: sigstore
  script:
    # Build and push
    - docker build -t $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA .
    - docker login -u $CI_REGISTRY_USER -p $CI_REGISTRY_PASSWORD $CI_REGISTRY
    - docker push $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA
    
    # Install cosign
    - apk add --no-cache curl
    - curl -LO https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
    - chmod +x cosign-linux-amd64 && mv cosign-linux-amd64 /usr/local/bin/cosign
    
    # Sign with GitLab OIDC
    - cosign sign --yes $CI_REGISTRY_IMAGE:$CI_COMMIT_SHA

With Key-Based Signing

# Store private key as CI secret (COSIGN_PRIVATE_KEY)
# Store password as CI secret (COSIGN_PASSWORD)

- name: Sign image (key-based)
  env:
    COSIGN_PRIVATE_KEY: ${{ secrets.COSIGN_PRIVATE_KEY }}
    COSIGN_PASSWORD: ${{ secrets.COSIGN_PASSWORD }}
  run: |
    echo "$COSIGN_PRIVATE_KEY" > cosign.key
    cosign sign --key cosign.key \
      ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ github.sha }}
    rm cosign.key

Attaching Metadata

Cosign can attach arbitrary metadata as attestations.

Attach an SBOM

# Generate SBOM with Syft
syft ghcr.io/myorg/myapp:v1.0.0 -o spdx-json > sbom.spdx.json

# Attach as attestation
cosign attest --yes \
  --predicate sbom.spdx.json \
  --type spdxjson \
  ghcr.io/myorg/myapp:v1.0.0

# Verify attestation exists
cosign verify-attestation \
  --type spdxjson \
  --certificate-identity-regexp=https://github.com/myorg/.* \
  --certificate-oidc-issuer=https://token.actions.githubusercontent.com \
  ghcr.io/myorg/myapp:v1.0.0

Attach Vulnerability Scan Results

# Scan with Grype, output as SARIF
grype ghcr.io/myorg/myapp:v1.0.0 -o sarif > vuln-scan.sarif

# Attach scan results
cosign attest --yes \
  --predicate vuln-scan.sarif \
  --type vuln \
  ghcr.io/myorg/myapp:v1.0.0

Custom Attestations

# Create custom metadata
cat > build-info.json << EOF
{
  "builder": "github-actions",
  "commit": "$GITHUB_SHA",
  "branch": "$GITHUB_REF",
  "timestamp": "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
}
EOF

# Attach as custom attestation
cosign attest --yes \
  --predicate build-info.json \
  --type https://myorg.com/attestations/build-info/v1 \
  ghcr.io/myorg/myapp:v1.0.0

Kubernetes Admission Enforcement

Signatures are useless without enforcement. Use admission controllers to verify images before deployment.

Sigstore Policy Controller

# Install
helm repo add sigstore https://sigstore.github.io/helm-charts
helm install policy-controller sigstore/policy-controller \
  -n sigstore-system --create-namespace
# Require signatures from your GitHub org
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-signed-images
spec:
  images:
    - glob: "ghcr.io/myorg/**"
  authorities:
    - keyless:
        identities:
          - issuer: "https://token.actions.githubusercontent.com"
            subjectRegExp: "https://github.com/myorg/.*"

Kyverno

apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
  name: verify-image-signatures
spec:
  validationFailureAction: Enforce
  webhookTimeoutSeconds: 30
  rules:
    - name: verify-signature
      match:
        any:
          - resources:
              kinds:
                - Pod
      verifyImages:
        - imageReferences:
            - "ghcr.io/myorg/*"
          attestors:
            - entries:
                - keyless:
                    subject: "https://github.com/myorg/*"
                    issuer: "https://token.actions.githubusercontent.com"
                    rekor:
                      url: https://rekor.sigstore.dev
          mutateDigest: true  # Replace tags with digests
          verifyDigest: true

Test Enforcement

# Deploy a signed image - should work
kubectl run signed --image=ghcr.io/myorg/myapp:v1.0.0

# Deploy an unsigned image - should fail
kubectl run unsigned --image=docker.io/nginx:latest
# Error: image signature verification failed

Inspecting Signatures

View Signature Details

# List signatures attached to an image
cosign tree ghcr.io/myorg/myapp:v1.0.0

# Output:
# 📦 Supply Chain Security Related artifacts for an image: ghcr.io/myorg/myapp:v1.0.0
# └── 💾 Attestations for an image tag: ghcr.io/myorg/myapp:sha256-abc123.att
#     └── 🍒 sha256:def456
# └── 🔐 Signatures for an image tag: ghcr.io/myorg/myapp:sha256-abc123.sig
#     └── 🍒 sha256:ghi789

Download and Inspect

# Download signature payload
cosign download signature ghcr.io/myorg/myapp:v1.0.0

# Download attestations
cosign download attestation ghcr.io/myorg/myapp:v1.0.0 | jq

# Check Rekor transparency log entry
REKOR_UUID=$(cosign verify ghcr.io/myorg/myapp:v1.0.0 2>&1 | grep -oP 'tlog entry created with index: \K\d+')
rekor-cli get --log-index $REKOR_UUID

Troubleshooting

”no matching signatures"

# Check what identity signed the image
cosign verify ghcr.io/myorg/myapp:v1.0.0 2>&1

# Common issues:
# 1. Wrong issuer (GitHub vs Google vs custom)
# 2. Wrong identity/subject pattern
# 3. Image was never signed
# 4. Signature was for different digest (tag was updated)

"UNAUTHORIZED: authentication required"

# Log in to registry first
docker login ghcr.io

# Or use environment variables
export COSIGN_REPOSITORY=ghcr.io/myorg/signatures
cosign sign --yes ghcr.io/myorg/myapp:v1.0.0

"certificate has expired”

Keyless certificates are valid for 10 minutes - long enough to sign, not to verify later. Verification uses the Rekor transparency log to prove the signature was created while the certificate was valid.

# Force verification against Rekor
cosign verify \
  --certificate-identity=... \
  --certificate-oidc-issuer=... \
  --rekor-url=https://rekor.sigstore.dev \
  ghcr.io/myorg/myapp:v1.0.0

Best Practices

1. Sign Digests, Not Tags

# Good - sign the immutable digest
cosign sign --yes ghcr.io/myorg/myapp@sha256:abc123

# Okay - cosign resolves tag to digest internally
cosign sign --yes ghcr.io/myorg/myapp:v1.0.0

# Bad - mutable tag could be replaced
kubectl set image deployment/myapp myapp=ghcr.io/myorg/myapp:latest

2. Use Specific Identity Patterns

# Bad - too broad
identities:
  - issuer: "https://token.actions.githubusercontent.com"
    subject: "*"

# Good - specific to your org
identities:
  - issuer: "https://token.actions.githubusercontent.com"
    subjectRegExp: "https://github.com/myorg/myapp/.github/workflows/.*"

3. Sign in CI, Not Locally

Local signing means private keys on developer machines. CI signing with keyless means:

  • No keys to manage
  • Audit trail of who signed what
  • Identity tied to verified CI workflow

4. Enforce Gradually

# Start with Audit mode
spec:
  validationFailureAction: Audit  # Log violations, don't block

# Then move to Enforce after validation
spec:
  validationFailureAction: Enforce  # Block unsigned images

Quick Reference

# Keyless sign
cosign sign --yes IMAGE

# Keyless verify
cosign verify \
  --certificate-identity=EMAIL_OR_PATTERN \
  --certificate-oidc-issuer=OIDC_ISSUER \
  IMAGE

# Key-based sign
cosign sign --key cosign.key IMAGE

# Key-based verify  
cosign verify --key cosign.pub IMAGE

# Attach SBOM
cosign attest --yes --predicate sbom.json --type spdxjson IMAGE

# View all artifacts
cosign tree IMAGE

# Download signature
cosign download signature IMAGE

Conclusion

Container image signing with Cosign is:

  • Free - Sigstore infrastructure costs nothing
  • Fast - Adds seconds to your pipeline
  • Keyless - No key management overhead
  • Verifiable - Transparency log proves everything

Start by signing images in CI. Then add enforcement in Kubernetes. Within a week, you’ll have cryptographic proof that every deployed image came from your build pipeline.

The next registry compromise won’t affect you.


References

Found this helpful?

Comments