← All writing

Keyless image signing with Cosign, and actually enforcing it

Adding a signing step to CI takes about ten minutes and changes nothing about your security posture. The value only arrives when something refuses to run an unsigned image — which is the part most guides skip.

What signing actually defends against

Be precise about the threat, or you will build the wrong control. Image signing answers one question: was this exact image produced by the pipeline I expect, from the repository I expect? It does not tell you the image is free of vulnerabilities, and it does not stop a compromised dependency — the pipeline will happily sign a backdoored build.

What it does close off:

  • Someone with registry write access pushing a malicious image over a known tag.
  • A tag being repointed to a different digest between build and deploy.
  • An image from an entirely different source being deployed by mistake or by a manipulated manifest.
Tags are not identity. myapp:v1.2.3 is a mutable pointer. Everything downstream of the build should reference the digest, myapp@sha256:…, and the signature is over that digest.

Why keyless

Traditional signing means a private key, which means storing it, rotating it, restricting who can use it, and revoking it when someone leaves. In CI that key typically ends up in a secret — reintroducing exactly the long-lived credential problem you were trying to solve.

Keyless signing uses the workflow's OIDC identity to obtain a short-lived certificate from Fulcio, signs with it, and records the signature in Rekor, a public transparency log. The certificate expires in minutes. There is no key to steal, and the signature carries the identity of the workflow that produced it — which is strictly more information than "someone who had the key".

Signing in the pipeline

permissions:
  id-token: write      # for the Fulcio certificate
  packages: write
  contents: read

steps:
  - name: Build and push by digest
    id: build
    uses: docker/build-push-action@v6
    with:
      push: true
      tags: ghcr.io/abdurahim50/gateway:${{ github.sha }}

  - name: Sign the image
    env:
      DIGEST: ${{ steps.build.outputs.digest }}
    run: |
      cosign sign --yes \
        ghcr.io/abdurahim50/gateway@${DIGEST}

Note that the signature is applied to @${DIGEST}, not to the tag. This matters: sign the tag and you have signed a pointer that can be moved afterwards.

Pin your actions too. Signing images while calling some-action@v3 is inconsistent — a moving tag on a third-party action is a write path into your build. Pin by commit SHA and let Dependabot raise the bumps as reviewable pull requests.

Attesting an SBOM

A signature proves origin. An attestation attaches a signed statement about the image — most usefully a software bill of materials, so that when the next ecosystem-wide CVE lands you can answer "are we affected?" from your registry rather than by rebuilding everything.

syft ghcr.io/abdurahim50/gateway@${DIGEST} \
  -o spdx-json > sbom.spdx.json

cosign attest --yes \
  --predicate sbom.spdx.json \
  --type spdxjson \
  ghcr.io/abdurahim50/gateway@${DIGEST}

Enforcing it at admission

This is the step that turns the previous work into a control. Using the Sigstore policy controller, a ClusterImagePolicy tells the cluster which identities it will accept signatures from:

apiVersion: policy.sigstore.dev/v1beta1
kind: ClusterImagePolicy
metadata:
  name: require-signed-images
spec:
  images:
    - glob: "ghcr.io/abdurahim50/**"
  authorities:
    - keyless:
        url: https://fulcio.sigstore.dev
        identities:
          # Both must match: who signed, and which workflow
          - issuer: https://token.actions.githubusercontent.com
            subject: https://github.com/abdurahim50/polyglot-microservices/.github/workflows/ci.yml@refs/heads/main

The subject constraint is the important line. Without it you are accepting any image signed by any GitHub Actions workflow anywhere — which is close to accepting everything.

Roll it out in warn mode first

Set the policy to warn rather than enforce, watch admission logs for a week, and fix the things you forgot were running — sidecars, init containers, third-party charts, the debug pod someone deploys by hand. Going straight to enforce on a live cluster is how you find out your ingress controller image is not covered, at the worst possible moment.

StageModeWhat you learn
Week 1WarnWhich workloads would have been blocked
Week 2Enforce in one namespaceWhether your exception path works
Week 3Enforce cluster-wideWhether you missed a system namespace

What this still doesn't cover

  • A compromised build. If an attacker gets code into main, the pipeline signs their artifact with full legitimacy. Branch protection and code review remain the control here, not signatures.
  • Base image content. Signing says nothing about what is inside. Scanning is a separate gate and both are needed.
  • Runtime behaviour. A correctly signed, clean image can still be exploited at runtime. Signing is about provenance, full stop.
The honest framing: signing narrows the window between "our build system produced this" and "this is what is running". That window is where several of the notable supply-chain incidents lived. It is a real gain, and it is a specific one.
Implementation lives in polyglot-microservices — pipeline deep-dive in docs/PIPELINE.md.

Corrections welcome — email me.