TL;DR: Signing a container image without verifying it at deploy time buys you nothing but a false sense of security — the signature has to be checked by something that can actually block a deployment. This walkthrough covers the two halves that make image signing meaningful in practice: producing a keyless Cosign signature and SLSA provenance attestation in CI, and enforcing verification at the Kubernetes admission layer so an unsigned or unattested image simply cannot run.
Why keyless signing replaced key-based signing as the default
Cosign, the signing tool from the Sigstore project, originally supported only key-based signing — generate a keypair, store the private key somewhere safe, sign with it. That's operationally exactly the static-credential problem security teams have spent the last several years eliminating everywhere else: a long-lived private key that needs storage, rotation, and access control, and that becomes a single high-value target if it leaks.
Keyless signing replaces the long-lived key with a short-lived one, minted at sign time and tied to a verified identity rather than to a secret you have to protect indefinitely:
- Your CI job requests an OIDC token from its own identity provider (GitHub Actions' built-in OIDC issuer, GitLab CI's equivalent, or another OIDC-capable CI system).
- Cosign generates an ephemeral keypair locally and sends the public key plus the OIDC token to Fulcio, Sigstore's certificate authority.
- Fulcio verifies the OIDC token against the issuer, and — if valid — issues a short-lived X.509 certificate (typically minutes) binding the ephemeral public key to the verified identity (e.g.,
repo:myorg/myapp:ref:refs/heads/main). - Cosign signs the image digest with the ephemeral private key, then discards it — there's nothing left to leak after the signature is created.
- The signature, certificate, and a record of the transaction are pushed to Rekor, Sigstore's public transparency log, giving you an immutable, publicly auditable record that this signing event happened, at this time, for this identity.
The practical upshot: nobody manages a signing key. Identity comes from the CI system's own OIDC issuer, which you were already trusting to run your builds in the first place — keyless signing doesn't add a new trust root, it reuses the one you already have.
Step 1: Signing an image in CI
A minimal GitHub Actions job producing a keyless Cosign signature:
name: build-and-sign
on:
push:
branches: [main]
permissions:
id-token: write # required for OIDC — this is the actual trust anchor
contents: read
packages: write
jobs:
build-sign:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Build and push image
run: |
docker build -t ghcr.io/myorg/myapp:${{ github.sha }} .
docker push ghcr.io/myorg/myapp:${{ github.sha }}
- name: Install cosign
uses: sigstore/cosign-installer@v3
- name: Sign image (keyless)
run: |
cosign sign --yes \
ghcr.io/myorg/myapp@$(docker inspect --format='{{index .RepoDigests 0}}' ghcr.io/myorg/myapp:${{ github.sha }} | cut -d'@' -f2)Two details that matter and are easy to get wrong on a first pass:
permissions: id-token: writeis the actual security boundary, not a formality. Without it, the job has no OIDC token to present to Fulcio, and signing fails outright — which is the correct failure mode, not a bug to work around by granting broader permissions.- Sign the digest, not the tag. Tags are mutable —
latestor even a branch-name tag can be repointed to a different image after the fact. A signature over a mutable tag reference doesn't protect against a tag being silently swapped; signing (and later verifying) the immutable digest is what actually binds the signature to one specific set of bytes.
Step 2: Reaching SLSA Build Level 3
Signing proves an image wasn't tampered with after signing. It says nothing about whether the build process itself was trustworthy — whether the build inputs were what they claimed to be, whether the build ran on infrastructure an attacker could influence, whether the signing step happened inside the same job an attacker-controlled test script could reach.
SLSA (Supply-chain Levels for Software Artifacts) formalizes exactly this: how much can you trust the process that produced the artifact. The distinguishing requirement at Build Level 3 is separation of duties — the entity that signs the provenance must be isolated from the build job itself, running on infrastructure the build job's own code cannot tamper with, with its own distinct OIDC token the build job can't access. A malicious dependency or a compromised test step inside your build can't forge a Level 3 attestation, because it has no path to the signing identity at all.
GitHub's own solution — artifact attestations, built on the slsa-github-generator reusable workflows — implements this separation for you without requiring you to stand up your own isolated signer:
- name: Generate build provenance
uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/myorg/myapp
subject-digest: sha256:${{ steps.build.outputs.digest }}
push-to-registry: trueThis runs as a distinct job invocation with its own OIDC token, separate from the job that ran docker build, satisfying the isolation requirement structurally rather than by convention. The result is a provenance attestation — a signed, machine-readable statement of what source commit, what workflow, what inputs produced this exact digest — pushed alongside the image.
Step 3: Verification — the step that's easy to skip and impossible to skip safely
An unverified signature is inert. It sits next to the image, provably genuine, and does precisely nothing to stop a bad image from running unless something in the deployment path checks it and refuses to proceed on failure.
Verifying with the CLI (for CI gates, local checks)
cosign verify \
--certificate-identity-regexp "^https://github.com/myorg/myapp/" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/myorg/myapp@sha256:abc123...gh attestation verify is the equivalent for GitHub's artifact attestations specifically, and is the tool GitHub recommends pairing with actions/attest-build-provenance — using matched tooling from the same ecosystem avoids subtle mismatches in how identity claims get parsed.
The identity constraints are not optional details. Verifying only that a valid Sigstore signature exists, without constraining which OIDC identity is acceptable, means any workflow with id-token: write anywhere — including an attacker's fork that happens to also sign things — passes verification. The --certificate-identity-regexp and --certificate-oidc-issuer flags are what turn "this is a Sigstore-signed image" (true of a huge number of images) into "this is an image signed by my build pipeline specifically" (the actual claim you need).
Enforcing verification at admission — where it actually matters
CI-time verification catches problems before deployment; it does not stop someone from manually applying a manifest with an unsigned image directly to the cluster, bypassing CI entirely. That requires a Kubernetes admission controller — Kyverno or OPA/Gatekeeper are the two common choices — configured to reject any Pod whose image reference isn't signed by the expected identity:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-image-signatures
spec:
validationFailureAction: Enforce
rules:
- name: verify-signature
match:
resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
attestors:
- entries:
- keyless:
subject: "https://github.com/myorg/*"
issuer: "https://token.actions.githubusercontent.com"With validationFailureAction: Enforce, an unsigned image — or one signed by an identity that doesn't match the policy — is rejected at the API server, before a Pod is ever scheduled. This is the control that converts "we sign our images" from a compliance checkbox into an actual technical guarantee that unsigned code cannot reach production. Start with Audit mode to see what would be blocked before flipping to Enforce — a policy that's too strict on day one, discovered via a failed production deploy, is a worse rollout than a week of audit logging first.
Common failure modes
Treating Rekor's transparency log as a private audit trail. Rekor entries are public by default — anyone can query the log. This is by design (transparency is the point), but it means the OIDC identity bound to a signature, and the fact that a signing event happened, is publicly visible. Don't rely on Sigstore's public instance for signing artifacts where the fact of signing itself needs to stay confidential; a private Rekor/Fulcio deployment exists for that case.
Verifying signature presence but not identity. Covered above, worth restating as the most common actual misconfiguration: cosign verify without identity constraints passes for any validly-signed image, from anyone.
Signing the tag instead of the digest, then deploying by digest. If your deployment manifests reference images by digest (the correct practice) but your signing step only signed the tag, verification at admission will fail — or worse, silently pass against the wrong reference if your tooling resolves loosely. Sign and verify the same reference form, and prefer digests throughout.
No verification path for images built before the policy existed. Rolling out admission enforcement on an existing cluster with a mix of signed and unsigned images already running requires either a grace period with Audit mode plus a backfill signing pass on existing images, or accepting that in-place Pods won't be touched (admission control only gates new scheduling, not already-running workloads) until the next rollout.
Wiring signing into CI is the easy half; enforcing verification at admission so it actually blocks something is where most rollouts stall. Syslabs' cybersecurity consulting practice builds both halves as part of supply-chain hardening engagements, alongside DevOps and platform engineering work on the CI/CD pipelines themselves.
Sources:
- Sigstore documentation, "Signing Containers"
- GitHub Blog, "Enhance build security and reach SLSA Level 3 with GitHub Artifact Attestations"
- GitHub Docs, "Using artifact attestations and reusable workflows to achieve SLSA v1 Build Level 3"
- slsa-framework/slsa-github-generator (GitHub)
- oneuptime.com, "How to Sign Container Images with Cosign" and "How to Verify Docker Image Signatures with Cosign"