The Mechanics of What You’re Actually Pinning
When a GitHub Actions workflow references actions/checkout@v4, the runner fetches whatever commit that tag currently points to. Git tags are mutable by default; a repository owner can force-push a new commit object under an existing tag name, and any workflow using that tag will silently execute the new code on its next run. This is not a theoretical concern: it is the mechanism behind the March 2025 tj-actions/changed-files compromise, one of the most widely documented supply chain attacks in the GitHub Actions ecosystem to date.
SHA pinning replaces the mutable tag reference with a commit object identifier:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683
A git commit SHA is computed over the commit message, author metadata, timestamp, parent commit identifiers, and the full file tree. You cannot change any of those fields without producing a different hash. When GitHub resolves that OID, you receive exactly the bytes that were present when the SHA was recorded. The integrity guarantee is real and cryptographically sound.
The problem is that integrity and safety are orthogonal properties. A pinned commit can contain malicious code from the moment it was written. A legitimate commit at the pinned SHA can download and execute arbitrary remote content at runtime. The bytes you verified tell you nothing about what those bytes will do once they start running.
What the tj-actions Incident Actually Demonstrates
The 2025 tj-actions/changed-files compromise is the clearest illustration of both sides of this. Attackers first compromised the reviewdog action ecosystem, extracted credentials from that system, used those credentials to obtain a token belonging to a tj-actions maintainer, then pushed malicious code directly to the tj-actions/changed-files repository and moved the existing version tags to point at the malicious commit. The payload dumped workflow secrets to run logs in a way that made them recoverable despite GitHub’s automatic redaction, affecting tens of thousands of repositories.
For repositories that had pinned tj-actions/changed-files to a specific commit SHA before the attack, nothing changed: their workflows continued running the uncompromised code. This is the scenario SHA pinning was designed to address, and it worked.
For repositories using Dependabot or Renovate to automate pin updates, the situation was different. These tools treat action version bumps like any other dependency update: they open a pull request pointing to whatever the latest tag currently references. During the window between tag mutation and public disclosure, those bots generated PRs pointing at the malicious commit. Repositories with auto-merge enabled for dependency update PRs moved onto the compromised code despite having been SHA-pinned beforehand. The automation that makes SHA pinning operationally sustainable also undermined it under exactly the conditions it was supposed to handle.
The Shallow Lock Problem
There is a structural limitation in how GitHub Actions resolves dependencies that SHA pinning cannot address at all.
A composite action at a pinned SHA can itself reference other actions by tag name in its action.yml. When the runner executes that composite action, it resolves those inner references at runtime from whatever the tags currently point to. Your pin governs the top-level commit; everything that commit invokes is free to change.
This is the difference between a shallow lock and a deep lock. npm’s package-lock.json records the exact version and integrity hash for every package in the full transitive dependency tree. When you run npm ci, npm verifies each tarball against its recorded SHA-512 hash before executing anything from it. Cargo’s Cargo.lock works similarly, and crates.io enforces an immutable registry model where published versions cannot be replaced. GitHub Actions SHA pinning is shallow: you lock the surface, not the graph.
JavaScript actions add another dimension to this. A Node.js action at a pinned SHA can fetch and execute remote content at runtime:
const https = require('https');
https.get('https://cdn.example.com/tool-setup.js', (res) => {
let payload = '';
res.on('data', chunk => payload += chunk);
res.on('end', () => eval(payload));
});
Many legitimate actions do something structurally similar when downloading tool binaries or installer scripts from CDNs and package registries as part of their setup process. SHA pinning cannot distinguish a malicious runtime fetch from a legitimate one, because the fetch is not part of what was pinned.
Compliance Incentives and the Scorecard Problem
The OpenSSF Scorecard’s Pinned-Dependencies check awards full credit when all uses: fields in workflow files contain commit SHAs rather than version tags. This check integrates with GitHub’s dependency insights tooling and appears in enterprise security dashboards. A project can score perfectly on this check while running actions that fetch arbitrary remote payloads, while granting GITHUB_TOKEN far broader permissions than the workflow requires, and while leaving transitive action dependencies tag-referenced throughout.
OpenSSF’s own documentation describes SHA pinning as a defense-in-depth measure, not a comprehensive control. The marketing has drifted from that framing. The effect is an incentive structure that rewards fixing what Scorecard can measure while leaving the rest untouched.
This is not a knock on Scorecard specifically. Any security benchmark faces the same pressure: the checks that are automatable get done, and the harder work of auditing what third-party actions actually do at runtime, reducing token permissions, or examining what pinned code fetches from the network gets deferred because none of that fits into a badge score.
What the Actual Blast Radius Depends On
If a compromised action runs in your CI environment, what it can do depends far more on what permissions you have granted than on whether the action was SHA-pinned. Most workflows operate with GITHUB_TOKEN scoped to whatever defaults the repository has configured, which is often more than necessary. Explicitly scoping token permissions limits what a compromised action can do with the credentials it receives:
permissions:
contents: read
pull-requests: write
A workflow that only needs to read code and post a PR comment does not need packages: write or id-token: write. The principle of least privilege applies to CI runners as much as it applies to any other execution context.
Network egress controls address the runtime fetch problem that SHA pinning cannot touch. StepSecurity’s harden-runner can enforce an allowlist of permitted outbound endpoints at the process level:
- uses: step-security/harden-runner@v2
with:
egress-policy: block
allowed-endpoints: >
api.github.com:443
registry.npmjs.org:443
This blocks unexpected outbound connections before they can exfiltrate anything, regardless of whether the action code at the pinned SHA was carefully audited. StepSecurity has repeatedly detected supply chain compromises through this kind of runtime monitoring rather than through static analysis of pinned commits.
For artifacts published from CI, SLSA provenance attestations and GitHub’s artifact attestation tooling built on Sigstore shift the question from “did I pin to the right SHA” to “can I cryptographically verify who built this artifact and from what source.” A binary published through a stolen maintainer token fails cosign verification if it was not produced by the official build workflow, because the signing identity is bound to the Actions execution context rather than to the user’s credentials alone:
cosign verify-blob \
--certificate-identity \
"https://github.com/myorg/mytool/.github/workflows/release.yaml@refs/tags/v1.0.0" \
--certificate-oidc-issuer \
"https://token.actions.githubusercontent.com" \
mytool_linux_amd64.tar.gz
This is a meaningfully different trust model. SHA pinning establishes that you received specific bytes unchanged. Attestation establishes that a specific, verifiable build pipeline produced those bytes from a specific source commit. The latter is harder to forge even when an account credential is stolen.
The Right Frame for SHA Pinning
Pinning your GitHub Actions dependencies to commit SHAs is worth doing. It removes one specific attack vector, the tag mutation attack that the tj-actions incident demonstrated, and it does so cheaply once you have tooling to manage the update cycle. The SHA is a real cryptographic guarantee about a specific, narrow thing.
What it is not is a trust boundary. The code at that SHA runs with the permissions you have granted, can reach whatever network endpoints your runner can reach, and can invoke further dependencies that are not covered by your pin. The integrity of the entry point says nothing about the trustworthiness of the execution.
The source article from Vaines.org makes this point directly, and it is worth sitting with: the security community has allowed SHA pinning to become a stand-in for “supply chain security” in a way that oversimplifies the actual threat model. The result is teams that feel covered from a narrow class of attacks while leaving their token permissions broad, their egress unrestricted, and their transitive dependencies fully mutable.
Pinning your actions is the floor. Scoping token permissions, auditing what third-party actions actually do at runtime, controlling egress, and using attestation for published artifacts are the rest of the building.