Stop and think about what your CI/CD pipeline can touch: the source code, the secrets, the build infrastructure, and production itself. No human on your team has that combination of access. That makes the pipeline one of the most valuable targets you own, because compromising it means owning everything downstream of it. Most teams harden production carefully and leave the thing that deploys to production wide open.
What you're defending against
- A compromised dependency that runs malicious code during install and exfiltrates your environment variables.
- A malicious pull request that edits the CI config to print out secrets.
- Stolen CI credentials used to trigger a build with modified code.
- Supply chain injection through a tampered build script or a poisoned cache.
Lock down the runners
Make them ephemeral. A fresh environment per job, destroyed afterwards, means secrets can't leak between jobs and malware can't persist.
If you self-host, isolate them. Dedicated network, jobs in containers, rotated credentials. A self-hosted runner on your corporate network is a tempting pivot point.
Grant minimal permissions, and prefer short-lived OIDC tokens over long-lived keys:
permissions:
id-token: write # OIDC instead of long-lived keys
contents: read
Scope the secrets
The lint job does not need the production database credentials. Scope secrets per environment, per branch, and per job so that a compromise of one stage doesn't hand over all of them.
The sharpest edge here is forked pull requests. Never expose secrets to a build triggered by a fork. The classic mistake is pull_request_target combined with checking out the PR's code, which runs an attacker's code with your secrets in scope.
Protect the pipeline config
The pipeline definition is code that runs with enormous privilege, so review changes to it like you'd review a change to production. Require review for anything under .github/workflows/.
And pin your actions to a commit SHA, because a tag can be quietly moved to point at malicious code:
# BAD - the tag can be moved under you
- uses: actions/checkout@v4
# GOOD - a SHA is immutable
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
Build something you can trust later
Reproducible builds. The same source should produce the same artefact, which means committing your lock files and pinning your inputs.
Provenance. Record where the artefact came from: the source commit, the builder's identity, the timestamp, and the artefact hash. When you need to prove a binary is what you think it is, that record is the proof.
Guard the deploy
Require human approval before production. Roll out with canaries rather than all at once, and make sure you can roll back quickly when a deploy goes wrong.
The principle to take away: your pipeline should be at least as hardened as the production it deploys to, because anyone who controls the pipeline controls production anyway.
