Container Image Signing with Cosign: Keyless, KMS-Backed, and Key-Based

You pull an image from a registry. It runs fine. But how do you actually know that image is what it claims to be, built from the source you trust, and not tampered with somewhere between the CI pipeline and your cluster? The honest answer for most teams is: you don’t.

The SolarWinds and Log4Shell incidents reminded the industry that supply chain attacks are real, patient, and devastating. Since then, SLSA, in-toto, and the Sigstore project have pushed image signing from "nice to have" into "you’d better have it" territory. CISA and NSA even issued guidance explicitly recommending cryptographic image verification for Kubernetes workloads.

This article covers cosign — the de-facto standard tool for signing and verifying OCI artifacts — across all three signing strategies you’ll encounter in the wild: key-based, KMS-backed, and keyless. By the end you’ll have working commands, real CI examples, and a clear picture of which method belongs where.

Official repo: https://github.com/sigstore/cosign


Why Most Teams Skip This (and Why That’s a Mistake)

The old Docker Content Trust system (DOCKER_CONTENT_TRUST=1) technically worked, but it was painful — Notary v1 was complex to operate, the UX was rough, and people quietly turned it off. So signing became something that large enterprises did and everyone else ignored.

Cosign changes the economics. It piggybacks signatures onto the same OCI registry you already use, requires no separate infrastructure for the basic cases, and the keyless flow requires zero key management at all. There’s no excuse anymore.


Installing Cosign

The binaries are on GitHub releases and also installable via common package managers.

# Linux x86_64
curl -Lo cosign https://github.com/sigstore/cosign/releases/latest/download/cosign-linux-amd64
chmod +x cosign
sudo mv cosign /usr/local/bin/

# macOS via Homebrew
brew install cosign

# Verify it works
cosign version

For CI, use the official GitHub Action (sigstore/cosign-installer) or pull the binary in your pipeline directly — pinned to a specific version hash, not latest. More on that in the Gotchas section.


Method 1: Key-Based Signing

This is the simplest conceptually. You generate a key pair, sign with the private key, distribute the public key to whoever needs to verify. Classical asymmetric cryptography.

Generating a Key Pair

cosign generate-key-pair

This spits out cosign.key (private, encrypted with a passphrase you set) and cosign.pub. Guard cosign.key like you’d guard an SSH private key to production. Don’t commit it. Don’t put it in Docker build context. Don’t email it around.

Signing an Image

# Sign by digest — ALWAYS use digest, not tag (tags are mutable)
cosign sign --key cosign.key \
  registry.example.com/myapp@sha256:abc123...

# You'll be prompted for the key passphrase
# To pass it non-interactively (for CI):
COSIGN_PASSWORD=supersecret cosign sign --key cosign.key \
  registry.example.com/myapp@sha256:abc123...

The signature gets pushed as a separate OCI artifact to the same registry, tagged with the image digest. No out-of-band database, no separate service.

Verifying

cosign verify --key cosign.pub \
  registry.example.com/myapp@sha256:abc123...

On success you get a JSON blob of the verified claims. On failure, exit code non-zero and an error. This is what your admission controller calls.

When to Use It

Key-based signing is appropriate when you need full offline verification (air-gapped environments), when you want no external dependencies, or when your security team insists on holding a specific key. The downside is key management — rotation, secure storage, passphrase distribution to CI runners. That operational burden is why KMS exists.


Method 2: KMS-Backed Signing

Instead of storing a key file, you reference a key managed by AWS KMS, GCP KMS, Azure Key Vault, or HashiCorp Vault. The private key never leaves the KMS; cosign calls the KMS API to perform the signing operation.

Supported KMS URI schemes:

Provider URI Format
AWS KMS awskms:///arn:aws:kms:region:account:key/key-id
GCP KMS gcpkms://projects/PROJECT/locations/LOCATION/keyRings/RING/cryptoKeys/KEY/versions/VERSION
Azure KV azurekms://VAULT_NAME.vault.azure.net/KEY_NAME
HashiCorp Vault hashivault://key-name

GCP Example (Most Common in My Experience)

First, create the key in GCP:

gcloud kms keyrings create cosign-ring \
  --location global

gcloud kms keys create cosign-key \
  --keyring cosign-ring \
  --location global \
  --purpose asymmetric-signing \
  --default-algorithm ec-sign-p256-sha256

Grant your CI service account permission to sign:

gcloud kms keys add-iam-policy-binding cosign-key \
  --keyring cosign-ring \
  --location global \
  --member serviceAccount:[email protected] \
  --role roles/cloudkms.signerVerifier

Now sign from CI:

cosign sign \
  --key gcpkms://projects/my-project/locations/global/keyRings/cosign-ring/cryptoKeys/cosign-key/versions/1 \
  registry.example.com/myapp@sha256:abc123...

No password prompts. The GCP SDK handles auth via the service account. Rotation is handled in KMS — create a new version, update the URI in your pipeline config, done. The old signatures remain valid because verification checks the public key, which KMS also exposes.

Exporting the Public Key for Verification

cosign public-key \
  --key gcpkms://projects/my-project/locations/global/keyRings/cosign-ring/cryptoKeys/cosign-key/versions/1 \
  > cosign.pub

Verification is identical to the key-based case — use the exported public key. You can safely commit cosign.pub to your repo or Kubernetes admission policy.

When to Use It

KMS is the right call for production workloads at any serious scale. Key rotation without downtime, audit logs built into the KMS, no secrets stored on CI runners. The cost is cloud provider lock-in and the KMS pricing (negligible for typical signing volumes, but worth knowing).


Method 3: Keyless Signing via Sigstore

This is where things get genuinely interesting. Keyless signing sounds like magic but it’s actually a clean piece of PKI engineering.

The flow: your CI job gets an OIDC token (from GitHub Actions, GitLab CI, Google Cloud, etc.), exchanges it at Fulcio (a certificate authority run by Sigstore), gets a short-lived signing certificate tied to your workload identity, signs the image, and logs the signature in Rekor (a public, append-only transparency log). The certificate expires after 10 minutes — no long-lived key to steal.

Verification later works by checking the Rekor log and confirming the certificate was issued to the expected identity (your GitHub repo, GCP service account, etc.).

Signing in GitHub Actions

This is the canonical keyless use case:

# .github/workflows/build-sign.yml
name: Build and Sign

on:
  push:
    branches: [main]

permissions:
  contents: read
  packages: write
  id-token: write   # REQUIRED — lets the runner get an OIDC token

jobs:
  build-sign:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Install cosign
        uses: sigstore/cosign-installer@v3
        with:
          cosign-release: v2.4.1   # Pin this. Don't use 'latest'.

      - name: Build and push image
        id: build
        run: |
          docker buildx build \
            --push \
            --tag ghcr.io/${{ github.repository }}:${{ github.sha }} \
            --iidfile /tmp/image-id \
            .
          # Capture the digest — sign by digest, not by tag
          echo "digest=$(docker inspect --format='{{index .RepoDigests 0}}' \
            ghcr.io/${{ github.repository }}:${{ github.sha }} | cut -d@ -f2)" \
            >> $GITHUB_OUTPUT

      - name: Sign image
        run: |
          cosign sign --yes \
            ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
        env:
          COSIGN_EXPERIMENTAL: "1"   # Required for keyless in cosign v1; v2 enables by default

The id-token: write permission is what allows the OIDC exchange. Without it, Fulcio rejects the request.

Verifying a Keyless Signature

Instead of a key, you verify against an identity and OIDC issuer:

cosign verify \
  --certificate-identity "https://github.com/myorg/myrepo/.github/workflows/build-sign.yml@refs/heads/main" \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  ghcr.io/myorg/myrepo@sha256:abc123...

This is precise: you’re asserting that the image was signed by a specific workflow in a specific repo on a specific branch. If an attacker compromises a different repo or a different workflow, the verification fails. That specificity is the whole point.

For a GKE workload:

cosign verify \
  --certificate-identity "SERVICE_ACCOUNT_EMAIL" \
  --certificate-oidc-issuer "https://accounts.google.com" \
  registry.example.com/myapp@sha256:abc123...

When to Use It

Keyless is the right default for cloud-native CI/CD. No secrets to manage, the transparency log gives you an audit trail, and the short-lived certs mean a compromised runner can’t be weaponized after the fact. The dependency on the Sigstore public good infrastructure (Fulcio + Rekor) is a concern for regulated environments — if you need air-gapped or private instances, you can self-host them, though that’s a significant operational undertaking.


Enforcing Signatures in Kubernetes

Signing images is pointless if nothing stops unsigned images from running. The two main options:

Policy Controller (Sigstore’s own):

helm repo add sigstore https://sigstore.github.io/helm-charts
helm install policy-controller sigstore/policy-controller \
  --namespace cosign-system --create-namespace
# ClusterImagePolicy example
apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-signed-images
spec:
  images:
    - glob: "ghcr.io/myorg/**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          - issuer: https://token.actions.githubusercontent.com
            subject: "https://github.com/myorg/myrepo/.github/workflows/build-sign.yml@refs/heads/main"

Kyverno is another popular option with a slightly more approachable policy language if you’re already using it for other admission policies.


Attaching SBOMs and Attestations

Cosign isn’t limited to signatures. You can attach any metadata as an attestation — SBOMs, vulnerability scan results, build provenance:

# Generate an SBOM with syft
syft ghcr.io/myorg/myapp:latest -o cyclonedx-json > sbom.json

# Attach it as an attestation (keyless)
cosign attest --yes \
  --predicate sbom.json \
  --type cyclonedx \
  ghcr.io/myorg/myapp@sha256:abc123...

# Verify the attestation
cosign verify-attestation \
  --certificate-identity "..." \
  --certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
  --type cyclonedx \
  ghcr.io/myorg/myapp@sha256:abc123...

This is the foundation of SLSA provenance. You’re building a verifiable chain of custody for every artifact that touches production.


Gotchas

Sign by digest, never by tag. Tags are mutable. Someone can push a different image under the same tag after you sign. The digest is the cryptographic hash of the content — that’s what you sign, that’s what you verify. Always resolve the tag to a digest before signing.

Pin cosign version in CI. Using cosign-installer@v3 with no version pin means your signing mechanism can change without warning. An attacker who compromises the installer action could swap in a version that doesn’t sign, or signs with a different key. Pin to a specific release and check the action’s hash.

Keyless requires network access to Fulcio and Rekor. If your CI runners are in an isolated network, keyless will silently fail or hang. Either open egress to fulcio.sigstore.dev and rekor.sigstore.dev, or use KMS/key-based signing. Alternatively, look at self-hosted Sigstore via the scaffolding repo.

Registry permissions for signature pushes. Signatures are stored in the same registry as the image, in a derived tag (sha256-<digest>.sig). Your CI service account needs write access to the registry, not just read. This catches people off guard when they tighten registry permissions and then wonder why signing breaks.

--yes flag. Starting with cosign v2, interactive flows prompt for confirmation. In CI, always pass --yes or set COSIGN_YES=1 to avoid hanging pipelines.

Rekor is a public transparency log. When you do keyless signing, your image digest and signing certificate (which includes your GitHub repo URL or service account email) get written to a public ledger. That’s intentional — it enables third-party auditing. But if your repo is private and you’d rather not expose its existence in a public log, use key-based or KMS signing, or run a private Rekor instance.


Comparison at a Glance

Key-Based KMS-Backed Keyless
Key management Manual KMS handles it None
Works offline Yes No No
Audit log External/none KMS audit Rekor (public)
CI complexity Low Medium Low (with OIDC)
Air-gap friendly Yes With private KMS Needs self-hosted Sigstore
Best for Simple setups, air-gap Regulated prod Cloud-native CI/CD

Putting It Together

A production-grade signing setup looks something like this: keyless signing in GitHub Actions for the main build pipeline (zero secret management, transparent audit trail), KMS-backed signing for release candidates where you want a durable key tied to a specific team, and a Kyverno or Policy Controller admission webhook in Kubernetes rejecting anything that doesn’t carry a valid signature from your trusted workflow.

Start with keyless if you’re on GitHub Actions or GCP — it’s genuinely the path of least resistance and the security properties are solid. Add attestations (SBOM, SLSA provenance) once the basic signing flow is green. The transparency log handles the rest.

The five minutes it takes to add cosign sign to your pipeline is the cheapest supply chain hardening you’ll ever do.

Leave a comment

👁 Views: 2,290 · Unique visitors: 1,647