You scan your code for vulnerabilities and you lock down what runs in your cluster, but the pipeline in between, the thing that turns your source into the artifact you deploy, is usually wide open. Nothing along that path proves that the image you are about to run is the one your build actually produced.
Attackers have noticed. In 2025, Sonatype catalogued more than 454,000 new malicious open-source packages, 99 per cent of them in the npm ecosystem, pushing the known total past 1.2 million (Trantor, 2026). And the pipeline itself is now a target: the GhostAction attack in early 2025 compromised a widely used GitHub Action, and because most repositories pinned it by tag rather than commit hash, every one of them automatically pulled the poisoned version, many with production deployment credentials attached (AquilaX, 2026).
Closing this gap takes three moves: sign every artifact so its origin is provable, attach provenance that records how and from what it was built, and verify both before anything runs. The tooling for this, Sigstore for signing and SLSA for provenance, used to be a multi-week project and is now an afternoon (AquilaX, 2026). This post is the practical version, with the workflow and policies to put it in place.
Sign every artifact, without managing a single key
The first move is to sign the image so anyone can prove it came from your pipeline. Sigstore's cosign does this with keyless signing: instead of a long-lived private key, the build's own identity becomes the credential. Sigstore, now a Linux Foundation project, issues a short-lived certificate, valid for around twenty minutes, tied to an OIDC identity, and records every signing event in an append-only public transparency log called Rekor (Trantor, 2026; Giant Swarm, 2025).
# .github/workflows/release.yml
permissions:
contents: read
packages: write
id-token: write # lets the job fetch an OIDC token, no stored keys
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: sigstore/cosign-installer@v3
- id: push
uses: docker/build-push-action@v6
with:
push: true
tags: ghcr.io/${{ github.repository }}:${{ github.sha }}
- name: Sign the image (keyless)
env:
IMAGE: ghcr.io/${{ github.repository }}
DIGEST: ${{ steps.push.outputs.digest }}
run: cosign sign --yes ${IMAGE}@${DIGEST}
The id-token: write permission lets the job mint an OIDC token, and cosign sign exchanges it for a short-lived certificate, signs the image by digest, stores the signature next to the image in the registry, and logs the event. There is no secret to leak, because there is no long-lived key.
The trade-off is that you depend on Sigstore's public infrastructure being reachable at signing time. For air-gapped or high-assurance environments you can run a private Sigstore instance, but that gives back some of the simplicity that made keyless attractive in the first place.
Provenance: record how and from what it was built
A signature proves who signed an artifact; provenance proves how it was built. A signature alone does not carry the data that a framework like SLSA requires, so you attach a signed attestation: tamper-evident metadata in the in-toto format that records which repository, which workflow, and which builder produced the artifact (Kyverno, 2026). This is what addresses the attacks an SBOM and a scanner cannot see, a compromise of the build system itself, as in SolarWinds and GhostAction (Trantor, 2026).
# add to the same job, after the image is pushed
- name: Generate SLSA build provenance
uses: actions/attest-build-provenance@v1
with:
subject-name: ghcr.io/${{ github.repository }}
subject-digest: ${{ steps.push.outputs.digest }}
push-to-registry: true
This generates a signed provenance statement for the image digest, built on Sigstore, and pushes it to the registry alongside the image so it travels with the artifact. Most teams should aim for SLSA Level 2, signed provenance from a hosted builder, which is a day or two of work, with Level 3 for critical components; because GitHub generates this attestation itself, the workflow cannot forge it, which satisfies SLSA Level 3 provenance (Trantor, 2026; qcecuring, 2026).
The trade-off is that provenance is only as useful as your verification of it. Generating attestations and never checking them is theatre, which is exactly why the next two steps matter more than this one.
Verify before you deploy, not after
Before the image runs, confirm two things: it was signed by your pipeline's identity, and it carries valid provenance from your repository. A verification step in the deploy job turns a signature from decoration into a control, because an unsigned or wrongly signed image fails the pipeline instead of reaching production. Verification checks both the signature and the identity chain, who built it and where, against the transparency log (SystemsArchitect, 2026).
# Fail the deploy unless the image was signed by our own pipeline
cosign verify \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--certificate-identity-regexp="^https://github.com/myorg/" \
ghcr.io/myorg/myapp@sha256:abc123...
# Verify the SLSA provenance attestation too
cosign verify-attestation --type slsaprovenance \
--certificate-oidc-issuer="https://token.actions.githubusercontent.com" \
--certificate-identity-regexp="^https://github.com/myorg/" \
ghcr.io/myorg/myapp@sha256:abc123...
The identity regular expression pins the signer to your organisation's GitHub workflows and the issuer to GitHub's OIDC, so anything signed by a different identity, or not signed at all, fails the check and stops the deploy.
The trade-off is reach. A verify step protects that one pipeline, but it does nothing about someone applying a manifest directly to the cluster. For that, the check has to live at the cluster boundary, not in the pipeline.
Enforce it at the cluster boundary
The strongest control is an admission policy that rejects any image the cluster cannot verify, so an unsigned image never starts, no matter how it arrived. This is the step that moves you from generating signals to enforcing them (Nirmata, 2026). Kyverno runs cosign verification at admission and refuses anything that does not match.
# Reject any image in the cluster that our pipeline did not sign
apiVersion: kyverno.io/v1
kind: ClusterPolicy
metadata:
name: require-signed-images
spec:
validationFailureAction: Enforce
rules:
- name: verify-signature
match:
any:
- resources:
kinds: [Pod]
verifyImages:
- imageReferences:
- "ghcr.io/myorg/*"
attestors:
- entries:
- keyless:
issuer: "https://token.actions.githubusercontent.com"
subjectRegExp: "^https://github.com/myorg/.+"
rekor:
url: "https://rekor.sigstore.dev"
The keyless attestor pins the issuer to GitHub's OIDC and the subject to your organisation's workflows, so any Pod whose image is not signed by that identity is denied at the webhook with a clear error, and only images signed by your trusted workflow are admitted (Secure Pipelines, 2026). It also rewrites the image reference to its digest, so what runs is exactly what was verified.
The trade-off is operational. Running straight into Enforce mode can block deploys the first time signing is skipped or misconfigured, so roll the policy out in audit mode, fix what it flags, and switch to enforce once the pipeline is reliably signing.
Close the door the attackers actually use: pin by digest
The cheapest and highest-value habit is to stop trusting mutable tags. A tag can be moved to point at new content, which is exactly how GhostAction spread: repositories pinned the Action by tag, so a single upstream compromise pushed malicious code to everyone using it (AquilaX, 2026). Pin third-party Actions and base images by their immutable digest instead.
# Vulnerable: a moved tag silently pulls new, possibly malicious code
- uses: some/action@v1
# Safe: a digest is immutable, so the code cannot change under you
- uses: some/action@a1b2c3d4e5f6... # v1.4.2
The hash is the exact bytes, so if the upstream is compromised, your pinned digest simply keeps building the known-good version until you deliberately update it. Pinning by digest prevents the entire class of attack where a tag is repointed to compromised content (Trantor, 2026).
The trade-off is readability and maintenance, since digests are opaque and need updating. The answer is a bot like Dependabot or Renovate to bump them for you, which keeps the immutability while still letting security updates land on a schedule you control.
The part worth sitting with
So go and look at how an image actually gets from your repository into your cluster, and ask what, on that whole path, checks that the artifact is yours. For most teams the honest answer is nothing, which is exactly why a single compromised Action or a single moved tag can ship malicious code straight to production wearing your pipeline's clothes. The fix is not one tool, it is a chain: sign every artifact with an identity instead of a key, attach provenance that records how it was built, verify both before you deploy, enforce that verification at the cluster so nothing unsigned can start, and pin your dependencies by digest so a moved tag cannot move you. None of it is a multi-week project any more. It is an afternoon of YAML, and it is the difference between a pipeline you hope is trustworthy and one you can prove is. The attackers have already industrialised this. The only question is whether your build can tell the difference between the artifact it made and one someone slipped in.
Author note
I am Mohan Gopi, an Associate DevOps Engineer at Frigga Cloud Labs. I work across AWS, GCP, and Azure, with GitHub Actions as the deployment backbone for everything I ship. The pattern I keep seeing is teams that have invested heavily in scanning their code and almost nothing in proving that the artifact in their registry is the one their pipeline built, which is the gap every modern supply-chain attack walks straight through. I started signing and verifying in my own pipelines the week GhostAction made the rounds, and what surprised me was how little it cost, an afternoon of workflow changes for a chain of trust that actually holds. I treat the build pipeline as part of production now, not a convenience that runs beside it, because an unverified artifact is just an incident that has not happened yet. Sign it, attest it, verify it, and let the cluster refuse anything it cannot prove. If you want to compare notes on wiring this into Actions, find me on LinkedIn → Mohan Gopi.
