SHA pinning in GitHub Actions has become a standard recommendation, endorsed by the OpenSSF Scorecard project, GitHub’s own security hardening documentation, and a growing ecosystem of automation tools. The practice is simple: instead of referencing an action by a mutable version tag, you pin to the full 40-character commit SHA.
# Mutable, vulnerable to tag reassignment
- uses: actions/checkout@v4
# SHA-pinned, immutable reference
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
A git SHA is content-addressed. If the SHA resolves, you received exactly that commit, cryptographically guaranteed. An attacker who compromises an upstream repository and force-pushes a malicious commit under an existing tag cannot touch your workflow if you pinned to a prior SHA. The piece at vaines.org that prompted this calls this the comforting lie: the practice is real, the guarantee is narrow, and the community has broadly mistaken the narrow guarantee for a comprehensive one.
That conflation matters because it shapes where teams focus their security effort.
What the SHA Guarantee Actually Covers
Git’s SHA guarantee is about content identity. When you pin to a SHA, you are asserting: the code that runs will be exactly this tree of files, no more, no less. That is a meaningful assurance against one specific attack class: an upstream maintainer (or an attacker who has compromised one) modifying what a version tag points to after you’ve reviewed the action.
The March 2025 compromise of tj-actions/changed-files demonstrated this attack class concretely. An attacker obtained a Personal Access Token belonging to a bot account with write access to the repository, pushed a malicious commit that dumped environment variables (including GITHUB_TOKEN and repository secrets) to stdout, and moved the v35 tag to point at that commit. Any workflow using @v35 immediately ran the exfiltration code on its next trigger. Workflows that had pinned to a prior known-good SHA were unaffected.
So SHA pinning works for this. The question is whether “this” is the problem you actually need to solve.
The Runtime Exfiltration Gap
SHA pinning proves that the code that started running was the code you expected. It says nothing about what that code does once it is running.
Actions execute with no sandbox between them and the rest of the job. A third-party action runs in the same process environment as your workflow steps, with full read access to all environment variables. Your secrets, your GITHUB_TOKEN, your AWS credentials: all accessible. An action can exfiltrate any of these via an outbound HTTP call, a DNS query, or simply by printing them to stdout in a log format your monitoring doesn’t flag.
The Codecov bash uploader incident from 2021 illustrated this vector through a different mechanism: the uploader script was served from Codecov’s CDN with a runtime dependency that had been replaced with a malicious version. Workflows that called curl https://codecov.io/bash | bash as a step were fully exposed. No amount of SHA pinning on the workflow’s uses: references changes anything about what happens inside a run: step that fetches and executes remote code.
But you don’t need a curl | bash pattern. A pinned action that calls npm install at runtime introduces the same unconstrained fetch. An action that pulls a Docker image from a registry with a mutable tag does the same. The SHA you pinned to is a verified starting point for an action that may promptly go out and fetch unverified dependencies before doing any actual work.
This is the class of attack SHA pinning cannot touch, and it is a large class.
The Auto-Update Paradox
There is a structural problem with how SHA pinning interacts with the tooling teams use to keep their dependencies current.
Pinned SHAs are opaque. A pinned workflow that references 11bd71901bbe5b1630ceea73d27597364c9af683 offers no obvious signal about whether that corresponds to a version from six months ago with known vulnerabilities. Teams that pin SHAs also need a process for updating them, which is where Dependabot and Renovate enter the picture.
Both tools support GitHub Actions via Dependabot’s github-actions ecosystem configuration and Renovate’s equivalent. They detect when a pinned SHA is behind the current release, open a PR updating the SHA, and (in many configurations) auto-merge it.
This creates a direct contradiction. SHA pinning is supposed to require explicit human review before new action code runs. Auto-merge on Dependabot PRs removes that review entirely. A team running SHA-pinned actions with auto-merged Dependabot updates has the operational complexity of SHA pinning with none of the security benefit: when the tj-actions/changed-files attacker moved the v35 tag, Dependabot would have opened a PR updating the SHA to the malicious commit, and an auto-merge configuration would have applied it before anyone noticed.
The correct operational posture is SHA pinning combined with a mandatory human review gate on all action update PRs. Renovate’s configurability makes this tractable: you can configure it to open PRs for action updates but require explicit approval before merging. But this only works if the team actually reviews those PRs rather than rubber-stamping them.
How OpenSSF Scorecard Shapes Incentives
The OpenSSF Scorecard project runs automated security checks on open source repositories and produces a numerical score across several dimensions. Its Pinned-Dependencies check rewards SHA pinning as a positive signal.
This creates a measurable, automatable proxy metric for supply chain security, which means it gets optimized. Tools like pinact and StepSecurity’s workflow hardening will convert all tag references to SHAs automatically. Run the tool, commit, Scorecard goes up, supply chain security theater is complete.
The check is not wrong. SHA pinning is better than not SHA pinning. But framing it as a scorable security property encourages teams to treat it as a destination rather than one layer in a deeper stack. A repository can score highly on Pinned-Dependencies while being fully exposed to runtime exfiltration, over-permissioned GITHUB_TOKEN, or injection via pull_request_target workflows.
The Poisoned Pipeline Execution class of attacks is a good example. pull_request_target runs workflows from the base branch with write access, even when triggered by a PR from a fork. If such a workflow checks out the fork’s code and runs it, you have arbitrary code execution in a trusted context. SHA pinning on the actions in that workflow is entirely orthogonal to this vulnerability.
What a Genuine Defense Looks Like
The goal is not to dismiss SHA pinning. It is to place it correctly in a broader set of controls.
Minimum permissions on GITHUB_TOKEN. Set permissions: read-all at the workflow level and grant write access only where explicitly required. A compromised action with a read-only token is far less damaging than one with write access to your repository, packages, and deployments.
permissions:
contents: read
pull-requests: write # only if this workflow needs it
Network egress monitoring. StepSecurity’s Harden-Runner instruments the runner at the eBPF level to monitor outbound connections and can block connections to non-allowlisted endpoints. This addresses the runtime exfiltration vector directly. The action itself must be trusted, which creates a bootstrapping consideration, but it is a meaningful control that SHA pinning cannot replicate.
Action allowlisting at the organization level. GitHub organization settings can restrict which actions are permitted to run in member repositories. Limiting to verified creator actions or an explicit allowlist reduces the attack surface regardless of how individual workflows reference those actions.
Required reviewers for action updates. If you use Dependabot or Renovate for action updates, configure them to require human review rather than auto-merging. The value of this is proportional to how seriously the review is taken.
Prefer forking for high-trust actions. Actions with access to deployment credentials, signing keys, or external service tokens warrant the overhead of a fork: copying the repository into your organization’s namespace, auditing changes before pulling upstream updates, and controlling the entire lifecycle. It is operationally expensive, but it is the only approach that fully eliminates the upstream compromise vector.
SLSA provenance attestations. GitHub’s actions/attest-build-provenance (released 2024) generates signed SLSA-conformant provenance records for build artifacts via Sigstore’s Rekor transparency log. This does not protect your runner, but it does give artifact consumers a verifiable chain of custody for what you shipped.
The Correct Mental Model
SHA pinning is a content integrity control. It answers the question: did the action code I ran match the specific version I reviewed? That is a useful question to answer. It is not the same question as: did my CI pipeline run safely?
Supply chain security in CI/CD is a collection of overlapping threat models: upstream code compromise, runtime behavior of trusted code, token scope abuse, injection via workflow inputs, maintainer account compromise, and more. SHA pinning addresses one of those, partially, under conditions that are easy to accidentally violate through common operational patterns.
The tj-actions incident was a clarifying moment. It showed that SHA pinning works when used correctly, that very few real-world workflows used it correctly, and that the operational practices surrounding it (auto-merge, broad token permissions) routinely cancel out the protection it would otherwise provide.
Pinning your SHAs is worth doing. Thinking that you have solved the problem after doing it is the mistake.