The actions/setup-node step controls which Node.js runtime and package registry your CI trusts, so hardening it is one of the highest-leverage things you can do for GitHub Actions supply chain security. Most teams copy a snippet from the marketplace, set node-version: 20, and never look again. That leaves three exposures wide open: an unpinned action reference, an unauthenticated registry, and a dependency cache that survives across untrusted branches.
This guide walks through a production-grade actions/setup-node configuration and the reasoning behind each field.
What actions/setup-node actually does
The action resolves a Node.js version, downloads it from the GitHub tool cache (or actions runner tool cache), puts node and npm on the PATH, and optionally writes an .npmrc for registry authentication and dependency caching. Every one of those steps trusts something: the action code itself, the version manifest it reads, and the registry it points npm at.
A minimal but safe invocation looks like this:
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.0.2
with:
node-version: '20.11.1'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
The difference between this and the marketplace default is entirely in the details below.
Pin the action by commit SHA, not a tag
actions/setup-node@v4 is a moving target. Tags are mutable references that the maintainer (or anyone who compromises the maintainer's account) can repoint at new code. The 2024 wave of GitHub Actions compromises showed how a single mutated tag propagates malicious steps into thousands of pipelines within hours.
Pin to the full 40-character commit SHA and leave the human-readable version in a trailing comment:
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.0.2
Dependabot and Renovate both understand this pattern and will bump the SHA with a PR when a new release lands, so pinning does not freeze you on an old version. It just means every upgrade is reviewed instead of silently pulled.
Lock the Node version exactly
node-version: 20 resolves to whatever the latest 20.x happens to be on the day the job runs. That is nondeterministic and makes "works on my machine" debugging miserable. Pin the full patch version, or read it from a checked-in file so the runtime matches local development:
- uses: actions/setup-node@<sha> # v4.0.2
with:
node-version-file: '.nvmrc'
Keeping the version in .nvmrc (or the engines field of package.json) means one source of truth drives both CI and developer laptops. When you upgrade, the diff is visible in review.
Protect npm registry authentication
Setting registry-url tells the action to write an .npmrc that reads an auth token from the NODE_AUTH_TOKEN environment variable. The mistake teams make is scoping that token too broadly or exposing it to steps that do not need it.
- uses: actions/setup-node@<sha> # v4.0.2
with:
node-version-file: '.nvmrc'
registry-url: 'https://registry.npmjs.org'
scope: '@your-org'
- run: npm ci
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
Two rules matter here. First, use a granular access token scoped to the specific packages you publish or the read-only feeds you consume, never a legacy classic token with full account access. Second, only inject NODE_AUTH_TOKEN on the steps that actually talk to the registry. A token sitting in the job-level env block is readable by every third-party action in the workflow.
Understand the caching threat model
cache: 'npm' restores node_modules-adjacent caches keyed on your lockfile hash. On a private repo among trusted maintainers this is fine. The problem is cache poisoning across trust boundaries: a workflow triggered by pull_request_target or one that runs on forked PRs can write to a cache that a later privileged job restores. If an attacker controls the contents of that cache, they control what your build installs.
Mitigations:
- Never combine dependency caching with
pull_request_targeton untrusted forks. - Scope caches so that fork PRs cannot write to the branch caches your release jobs read.
- Prefer
npm ciovernpm installso installs are driven strictly by the committedpackage-lock.json, not by whatever the cache happened to contain.
Because the cache is keyed on the lockfile, a poisoned or drifting lockfile is the real risk. An SCA tool such as Safeguard can flag when a transitive dependency in your lockfile ships a known-malicious or vulnerable version before it ever reaches the cache.
Enforce a clean, reproducible install
The single most important line after setup-node is the install command. Use npm ci, which errors out if package.json and package-lock.json disagree, deletes any existing node_modules, and installs the exact tree the lockfile describes:
- run: npm ci --ignore-scripts
Adding --ignore-scripts blocks lifecycle scripts (preinstall, postinstall) from running during install. Many software supply chain attacks execute their payload precisely in a postinstall hook. If your build genuinely needs a package's install script, allow-list that single package rather than leaving scripts on globally. For a deeper look at how install-time scripts are abused, our write-up on the npm dependency confusion problem covers the mechanics.
A hardened reference workflow
Putting it together:
permissions:
contents: read
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@<sha> # v4
- uses: actions/setup-node@1d0ff469b7ec7b3cb9d8673fde0c81c44821de2a # v4.0.2
with:
node-version-file: '.nvmrc'
cache: 'npm'
registry-url: 'https://registry.npmjs.org'
scope: '@your-org'
- run: npm ci --ignore-scripts
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
- run: npm run build
Note the job-level permissions: contents: read. setup-node does not need write access to anything, and least-privilege GITHUB_TOKEN scoping limits the blast radius if any step in the job is compromised.
FAQ
Should I pin actions/setup-node to a SHA or a version tag?
Pin to the full commit SHA. Tags are mutable and can be repointed at malicious code by anyone who compromises the maintainer account. Keep the version in a comment and let Dependabot or Renovate bump the SHA through reviewed pull requests.
Does the cache option in actions/setup-node cache node_modules?
Not directly. It caches npm's download cache keyed on your lockfile hash, then npm ci reinstalls from it. The risk is cache poisoning across trust boundaries, so never pair caching with untrusted fork workflows, and always install with npm ci rather than npm install.
How do I stop postinstall scripts from running in CI?
Run npm ci --ignore-scripts. Lifecycle scripts are a common malware execution point. If a legitimate dependency requires its install script, allow-list that one package instead of enabling scripts globally.
Is node-version 20 safe to use?
It works but is nondeterministic because it resolves to the latest 20.x at run time. Pin an exact patch version or read it from a checked-in .nvmrc so CI and local development stay in sync and upgrades are visible in review.