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.
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.
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.
| Stage | Mode | What you learn |
|---|---|---|
| Week 1 | Warn | Which workloads would have been blocked |
| Week 2 | Enforce in one namespace | Whether your exception path works |
| Week 3 | Enforce cluster-wide | Whether 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.
docs/PIPELINE.md.
Corrections welcome — email me.