
Your CI pipeline builds an image, pushes it to a registry, and your cluster pulls it. But how does the cluster know the image it pulls is the one your pipeline built? A registry tag can be overwritten, and a compromised credential can push anything under that tag. Most supply-chain incidents in this space don't come from a bug in your application code — they come from something getting swapped in between "built" and "running" without anyone noticing. Image signing closes that gap: the pipeline signs what it builds, and the deploy side refuses anything without a valid signature.
In this tutorial you will sign container images with Cosign (part of the Sigstore project) from GitHub Actions, verify them locally, attach a software bill of materials (SBOM) as a signed attestation, and enforce both the signature and the attestation in Kubernetes with Kyverno.
Why keyless signing
Cosign supports two modes. With key-based signing you generate a key pair and must store and rotate the private key safely — lose it and you can't sign; leak it and anyone can sign as you. With keyless signing there is no long-lived key at all:
- Your CI job proves its identity with a short-lived OIDC token (GitHub issues one per workflow run).
- Sigstore's Fulcio certificate authority issues a short-lived certificate (valid for about 10 minutes) bound to that identity.
- Cosign signs the image digest with the matching private key, discards the key immediately, and records the signature in the Rekor transparency log.
Verification then checks who signed the image (the workflow identity, recorded in the certificate) rather than which key signed it. That removes the biggest operational risk of signing: a key sitting in a secrets manager for months, waiting to be leaked or forgotten.
Step 1: Sign in GitHub Actions
The job needs the id-token: write permission so GitHub can mint the OIDC token. Always sign the image digest, never a mutable tag — a tag can be repointed after you signed it, a digest can't.
name: build-and-sign
on:
push:
branches: [main]
permissions:
contents: read
packages: write
id-token: write # required for keyless signing
jobs:
build:
runs-on: ubuntu-latest
outputs:
digest: ${{ steps.build.outputs.digest }}
steps:
- uses: actions/checkout@v4
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/setup-buildx-action@v3
- name: Build and push
id: build
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- uses: sigstore/cosign-installer@v3
- name: Sign the image by digest
run: |
cosign sign --yes \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
The --yes flag skips the interactive confirmation, which is required in CI. Note the job exposes digest as an output — the later attestation and verification steps need the exact digest, not the tag, so pass it along explicitly rather than re-deriving it.
Step 2: Verify the signature
To verify a keyless signature you must say which identity you trust. This is the important part: without it, anyone with a valid Sigstore certificate (which is free and automatic for any GitHub Actions workflow anywhere) could sign an image and pass verification.
cosign verify \
--certificate-identity-regexp "^https://github.com/YOUR_ORG/YOUR_REPO/\.github/workflows/build-and-sign\.yml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/YOUR_ORG/YOUR_REPO@sha256:<digest>
Two flags do the real work:
--certificate-identity-regexppins the exact workflow file and branch that is allowed to sign.--certificate-oidc-issuerpins the identity provider (GitHub Actions here).
A successful verification prints the certificate's identity and a JSON payload describing the signed subject, roughly like this (trimmed for readability — the real output includes the full certificate chain):
Verification for ghcr.io/your-org/your-repo@sha256:3f29a... --
The following checks were performed on each of these signatures:
- The cosign claims were validated
- The claims were present in the transparency log
- The signatures were integrity verified
- Any certificates were verified against the Fulcio roots
[{"critical":{"identity":{"docker-reference":"ghcr.io/your-org/your-repo"},
"image":{"docker-manifest-digest":"sha256:3f29a..."},
"type":"cosign container image signature"},
"optional":{"Issuer":"https://token.actions.githubusercontent.com",
"Subject":"https://github.com/your-org/your-repo/.github/workflows/build-and-sign.yml@refs/heads/main"}}]
If the image was never signed, or was signed by a different workflow, the command exits non-zero with Error: no matching signatures. That makes it easy to use as a gate in a deploy job — no output parsing needed, just check the exit code.
Step 3: Verify before you deploy
Add a verification step to the job that deploys, so a missing or invalid signature stops the release before kubectl apply ever runs:
deploy:
needs: build
runs-on: ubuntu-latest
steps:
- uses: sigstore/cosign-installer@v3
- name: Verify image signature
run: |
cosign verify \
--certificate-identity-regexp "^https://github.com/${{ github.repository }}/\.github/workflows/build-and-sign\.yml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/${{ github.repository }}@${{ needs.build.outputs.digest }}
Step 4: Attach an SBOM as a signed attestation
A signature proves who built the image. An attestation can also prove what's inside it. The most common one is a software bill of materials — a list of every package and library the image contains, useful for answering "are we affected by this new CVE" without scanning every running cluster from scratch.
Generate an SBOM with Syft in CycloneDX format, then attach it with cosign attest:
- uses: anchore/sbom-action/download-syft@v0
- name: Generate SBOM
run: |
syft ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }} \
-o cyclonedx-json=sbom.json
- name: Attest the SBOM
run: |
cosign attest --yes \
--predicate sbom.json \
--type cyclonedx \
ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
Like the signature, the attestation is keyless and lands in Rekor. You can inspect it the same way you'd inspect a signature:
cosign verify-attestation \
--type cyclonedx \
--certificate-identity-regexp "^https://github.com/YOUR_ORG/YOUR_REPO/\.github/workflows/build-and-sign\.yml@refs/heads/main$" \
--certificate-oidc-issuer "https://token.actions.githubusercontent.com" \
ghcr.io/YOUR_ORG/YOUR_REPO@sha256:<digest>
Step 5: Enforce both in the cluster
Verifying in CI protects your pipeline, but someone can still run kubectl apply by hand with an unsigned image, or with a correctly signed image that has no SBOM attached. To close that gap, enforce verification at admission time. Two common options:
- Kyverno with a
verifyImagesrule that checks the signature, the expected identity, and optionally that a named attestation exists. - Sigstore policy-controller, a Cosign-aware admission controller maintained by the Sigstore project.
A Kyverno policy that requires both the signature and the SBOM attestation:
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: verify-signed-images
spec:
validationFailureAction: Enforce
webhookTimeoutSeconds: 30
rules:
- name: require-cosign-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "ghcr.io/YOUR_ORG/*"
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github.com/YOUR_ORG/.*/\.github/workflows/build-and-sign\.yml@refs/heads/main$"
rekor:
url: https://rekor.sigstore.dev
attestations:
- predicateType: cyclonedx
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github.com/YOUR_ORG/.*/\.github/workflows/build-and-sign\.yml@refs/heads/main$"
Start with validationFailureAction: Audit to see what would be blocked, then switch to Enforce once your images (and their SBOMs) are signed.
Multi-arch images
If docker/build-push-action builds for more than one platform (e.g. linux/amd64,linux/arm64), the digest it pushes is the digest of the manifest index — the OCI object that points at one image per architecture, not any single platform image. Sign that index digest, not an individual platform digest:
# steps.build.outputs.digest is already the index digest for a multi-platform build
cosign sign --yes ghcr.io/${{ github.repository }}@${{ steps.build.outputs.digest }}
One signature on the index covers every platform under it — you do not need to sign each architecture separately, and cosign verify against the index digest verifies the whole set.
Private registries and a public transparency log
Cosign reuses whatever registry credentials are already active — the docker/login-action step earlier in the job is enough; there is no separate cosign login step required for most registries. The signature itself is stored as another artifact in the same registry, next to the image, under a tag derived from the digest (sha256-<digest>.sig), so it inherits the same access controls as the image.
The one thing that does not stay private by default is the Rekor entry. Keyless signing records the image digest and the signer's identity (your repo and workflow path) in Sigstore's public transparency log, even for an image in a private registry — the log entry doesn't include the image content, but it does confirm that a specific digest was signed by a specific workflow at a specific time. For most teams that's a feature, not a problem — it's independent proof you can point to later. If your organization has a reason the existence of an internal repo and workflow name can't appear in a public log, Sigstore supports running a private Rekor instance; that's a larger infrastructure decision outside the scope of this tutorial.
Troubleshooting
Error: getting signer: getting key from Fulcio: generating cert— usually meansid-token: writeis missing from the job'spermissionsblock. GitHub silently issues a restricted token without it.Error: no matching signatureson an image you believe is signed — check you're verifying by digest, not by tag; a tag can have moved since signing.none of the attestations matched the predicate type— the--typeflag onverify-attestationmust match the--typeused incosign attestexactly (e.g.cyclonedxvsspdx).- Regexp special characters —
.in a repo or workflow path is a regex wildcard. Escape it (\.github) or an overly permissive pattern will match more identities than intended.
Common pitfalls
- Signing tags instead of digests. A tag can later point to a different image than the one you signed. Sign, attest, and deploy by digest.
- A loose identity match. A regexp like
.*accepts any signer. Pin the repository, workflow file, and branch. - Missing
id-token: write. Without it the signing step fails with an OIDC token error. - Forgetting third-party images. Your policy should only cover images you own, or you will block system and vendor images you never signed.
Where to go next
Signing proves who built an image, and the SBOM attestation proves what's inside it. The next step most teams take is wiring a vulnerability scanner (Trivy or Grype) into the same pipeline to scan the SBOM and attach the scan result as a second attestation — giving you a verifiable chain from source commit, to build, to "this image was scanned and found clean at build time," all enforced the same way at the cluster boundary.

Experienced DevOps Engineer proficient in Python, Docker, Kubernetes, CI/CD, and cloud technologies. Specialized in automating processes and building scalable infrastructure for seamless development and deployment.


