Disabling npm Install Scripts
Permalink to "Disabling npm Install Scripts"Part of Registry & Package Manager Hardening, this page shows how to stop dependency lifecycle scripts from executing during installation, and how to give back that privilege to the small number of packages that genuinely cannot work without it.
Installing a dependency is not a passive act. Between resolving the tree and writing files to node_modules, npm hands control to code written by every package author in that tree, running with your shell’s user, your environment variables and your network access. Disabling that handoff is the single highest-leverage change most repositories can make to their install pipeline, and the reason it is not the default is compatibility, not safety.
Quick Reference
Permalink to "Quick Reference"| Control | Where it lives | Effect |
|---|---|---|
--ignore-scripts |
npm CLI flag | Skips preinstall, install, postinstall and prepare for this run |
ignore-scripts=true |
.npmrc (project, user or global) |
Same, applied to every npm command run from that directory |
npm config set ignore-scripts true --location=project |
npm CLI | Writes the setting into ./.npmrc for you |
NPM_CONFIG_IGNORE_SCRIPTS=true |
environment variable | Same setting, useful in a container or CI job |
npm rebuild <pkg> --ignore-scripts=false |
npm CLI | Re-runs the install lifecycle for one named package |
--foreground-scripts |
npm CLI flag | Streams lifecycle script output instead of hiding it |
hasInstallScript: true |
package-lock.json v2+ |
Lockfile marker for a package that declares an install script |
onlyBuiltDependencies |
pnpm config | Allow-list of packages permitted to run scripts |
enableScripts: false |
.yarnrc.yml |
Yarn Berry’s global off switch |
Default posture: scripts off in .npmrc, an explicit rebuild list checked into the repository, and a periodic audit of which dependencies have started declaring scripts since the last review.
The mental model
Permalink to "The mental model"npm runs three dependency-owned hooks during an install. preinstall fires before the package’s own dependencies are resolved, install fires after extraction, and postinstall fires once the package is placed in the tree. A fourth hook, prepare, runs for the root project and for dependencies installed straight from a git URL. All four are plain shell commands taken from the package’s package.json, and npm executes them without prompting, without sandboxing and without any signature check on the code they invoke.
The consequence is that the blast radius of npm install is the whole transitive tree, not just the packages you chose. A typo-squatted name three levels down, a compromised maintainer account, or a version range that quietly resolves to a new release are all sufficient to get arbitrary code onto a developer laptop or a CI runner. The code runs before a single test executes and before anyone reviews a diff.
Turning the hooks off costs you very little, because the overwhelming majority of packages declare no install script whatsoever. In a typical application tree of a few thousand entries, the number with an install hook is usually in the single digits or low tens, and most of those are build tooling rather than runtime code. That short list is what the rest of this page is about: find it, decide package by package whether the build is required, and re-run only those.
Canonical example: scripts off by default, allow-list to re-enable
Permalink to "Canonical example: scripts off by default, allow-list to re-enable"Commit the setting to the repository so it applies to every developer and every job, rather than relying on each person remembering a flag.
# writes ignore-scripts=true into ./.npmrc, which you then commit
npm config set ignore-scripts true --location=project
The resulting file is two lines and belongs in version control alongside your registry configuration:
; .npmrc — committed
ignore-scripts=true
engine-strict=true
From this point on, npm install and npm ci extract packages without executing anything. Packages that need a build step are re-run by name, after the install, from a script your team can read and review:
{
"scripts": {
"install:safe": "npm ci --ignore-scripts && npm run rebuild:allowed",
"rebuild:allowed": "npm rebuild --ignore-scripts=false esbuild @parcel/watcher better-sqlite3"
}
}
npm rebuild accepts several package names in one invocation and re-runs the install lifecycle for each of them in place, without touching the lockfile or the resolved tree. The --ignore-scripts=false argument is not redundant: npm rebuild reads the same configuration key as npm install, so with ignore-scripts=true in .npmrc a bare npm rebuild will dutifully rebuild nothing. --no-ignore-scripts is an accepted equivalent spelling.
The decision for each candidate package follows the same shape every time.
The three families that end up on the right-hand branch are predictable. Native addons compiled locally by node-gyp need their install hook or there is no .node binary to load. Tools that fetch a platform-specific executable at install time, historically puppeteer and cypress, need either their hook or an explicit follow-up command such as npx puppeteer browsers install chrome or npx cypress install. Repository tooling like husky wires git hooks from the root project’s prepare script, which the same flag suppresses. Notably, several packages have moved off install scripts entirely: sharp from version 0.33 and esbuild’s platform packages both ship prebuilt binaries as optional dependencies, so they often work with scripts disabled and no rebuild at all.
Variants
Permalink to "Variants"pnpm 10 and the onlyBuiltDependencies allow-list
Permalink to "pnpm 10 and the onlyBuiltDependencies allow-list" pnpm inverted the default in version 10: dependency lifecycle scripts are blocked unless the package is explicitly permitted, which is the posture the npm recipe above has to be assembled by hand. The allow-list lives under the pnpm key in package.json, and recent 10.x releases also read the same key from pnpm-workspace.yaml:
{
"pnpm": {
"onlyBuiltDependencies": ["esbuild", "@parcel/watcher"]
}
}
When an install encounters a blocked package, pnpm prints a notice rather than failing. pnpm approve-builds walks the blocked list interactively and writes your choices into the allow-list, which is the fastest way to bootstrap the file on an existing repository. The blunt instrument still exists too — pnpm config set ignore-scripts true and pnpm install --ignore-scripts behave as they do under npm — and pnpm rebuild <pkg> is the counterpart to npm rebuild.
Yarn Berry and enableScripts
Permalink to "Yarn Berry and enableScripts" Yarn 2 and later expose a global switch in .yarnrc.yml:
# .yarnrc.yml
enableScripts: false
nodeLinker: node-modules
Per-package control is the dependenciesMeta.<pkg>.built flag in package.json. Setting it to false blocks the build for one dependency while leaving the rest enabled, which is a deny-list rather than an allow-list. The interaction between that flag and a global enableScripts: false has not been stable across Yarn releases, so verify the behaviour against your pinned Yarn version before depending on it in CI; the portable form is to leave enableScripts at its default and deny-list the packages you do not trust. For a single run, yarn install --mode=skip-build skips build steps without editing configuration, and Yarn Classic accepts the familiar yarn install --ignore-scripts.
CI installs with scripts disabled
Permalink to "CI installs with scripts disabled"CI is where the flag pays for itself, because a runner has credentials a laptop usually does not. Pass it explicitly rather than trusting the checked-in .npmrc to be read, then rebuild the allow-list:
# .github/workflows/build.yml
- name: Install without lifecycle scripts
run: npm ci --ignore-scripts
- name: Rebuild trusted native packages
run: npm rebuild --ignore-scripts=false esbuild better-sqlite3
- name: Build
run: npm run build
Keep the install itself reproducible — npm ci fails on any lockfile drift, and the equivalent guarantees in other managers are covered in npm ci vs pnpm --frozen-lockfile vs yarn --immutable. Disabling scripts and pinning the tree are complementary: one removes the execution path, the other removes the surprise version that would have used it.
Auditing which dependencies declare lifecycle scripts
Permalink to "Auditing which dependencies declare lifecycle scripts"You cannot maintain an allow-list you have never enumerated. Lockfile version 2 and later record a hasInstallScript boolean on every package entry that declares preinstall, install or postinstall, which makes the inventory a pure lockfile read with no network access and no install required. The broader techniques for walking that file are covered in Parsing package-lock.json for Dependency Audits.
// scripts/list-install-scripts.mjs — run: node scripts/list-install-scripts.mjs
import { readFileSync } from 'node:fs';
const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'));
if (!lock.packages) {
console.error('Lockfile is v1; run `npm install` with npm 7+ to upgrade it.');
process.exit(2);
}
const found = Object.entries(lock.packages)
.filter(([path, meta]) => path !== '' && meta.hasInstallScript)
.map(([path, meta]) => ({
name: path.replace(/^.*node_modules\//, ''),
version: meta.version,
scope: meta.dev ? 'dev ' : 'prod',
resolved: meta.resolved ?? '(none)',
}))
.sort((a, b) => a.name.localeCompare(b.name));
for (const p of found) {
console.log(`${p.scope} ${p.name}@${p.version}`);
}
console.log(`\n${found.length} package(s) declare an install lifecycle script`);
Run it, then read the actual command each one would have executed before deciding anything:
jq '.scripts' node_modules/esbuild/package.json
Commit the output of the script as a checked-in text file and diff it in review. A dependency bump that turns a previously script-free package into one with a postinstall is exactly the signal worth catching by hand, and it is invisible in a normal lockfile diff unless you know to look for the flag.
The matrix matters for one practical reason: in a mixed organisation, the same repository can be installed by three different tools depending on whose machine it is. Pinning the manager removes that variable, which is why the script control and the manager version are usually rolled out together.
Gotchas and Edge Cases
Permalink to "Gotchas and Edge Cases"-
The flag silences your own project’s scripts too.
--ignore-scriptsis not scoped to dependencies. Your rootprepare,postinstalland friends are skipped as well, which is why husky stops installing git hooks and whypatch-packagestops applying patches. Runnpm run prepareornpm run postinstallas an explicit follow-up step; scripts invoked throughnpm runare unaffected by the setting. -
npm rebuildreads the same config key. Withignore-scripts=truein.npmrc, a plainnpm rebuild esbuildcompletes successfully and does nothing at all, which is a genuinely confusing failure mode because there is no error. Always pass--ignore-scripts=false, and add--foreground-scriptswhile debugging so you can see the hook produce output. -
Optional dependencies are a second binary delivery channel. Packages that ship prebuilt binaries as platform-specific optional dependencies never needed an install hook, so disabling scripts does not affect them. The tarball for your platform is still downloaded and extracted. That is a smaller risk than arbitrary execution at install time, but it is not zero, and it means “no install scripts in the tree” is not the same claim as “no third-party binaries in the tree”.
-
npm auditwill never flag an install script. Advisory databases describe known vulnerabilities in known versions; a brand-new package whose only payload is apostinstallhas no advisory to match against, so triage of the sort described in Triaging npm audit Findings will report a clean tree. The two controls cover disjoint failure modes. -
A new script can appear in a routine version bump.
hasInstallScript: trueshowing up on a lockfile entry that did not have it is one of the highest-signal lines a reviewer can look for, and it is easy to scroll past in a thousand-line diff. Automate the check rather than trusting attention, as described in Detecting Lockfile Tampering in Pull Requests.
Verification Steps
Permalink to "Verification Steps"1. Confirm the setting is actually in effect
Permalink to "1. Confirm the setting is actually in effect"npm config get ignore-scripts
Expected output is true. If it prints false, npm is not reading the .npmrc you edited — check the working directory, and use npm config list -l | grep -i ignore to see which file supplied the value.
2. Prove nothing ran during a clean install
Permalink to "2. Prove nothing ran during a clean install"rm -rf node_modules && npm ci --foreground-scripts 2>&1 | tee install.log
grep -cE '^> .+@[0-9].+ (pre|post)?install' install.log
--foreground-scripts streams lifecycle output into the terminal instead of buffering it away, so the grep count is your evidence. Expected output is 0.
3. Prove the allow-listed rebuild does run
Permalink to "3. Prove the allow-listed rebuild does run"npm rebuild --ignore-scripts=false --foreground-scripts esbuild
This should print a > esbuild@<version> postinstall header followed by the hook’s own output. If the command returns instantly with no header, the --ignore-scripts=false argument is missing or a stale node_modules entry is already marked as built.
4. Reconcile the inventory against the allow-list
Permalink to "4. Reconcile the inventory against the allow-list"node scripts/list-install-scripts.mjs
Every name printed must be either in your rebuild list or a package you have consciously decided to leave unbuilt. Anything else is a package running or skipping a build by accident, and both directions are worth resolving before the next release.
Frequently Asked Questions
Permalink to "Frequently Asked Questions"Does --ignore-scripts also block my own project's scripts?
Yes. The flag suppresses lifecycle scripts for the root project as well as for dependencies, which means preinstall, install, postinstall and prepare in your own package.json are skipped too. Scripts you invoke explicitly with npm run are unaffected, so the usual fix is to call npm run prepare as a separate step after the install.
Which packages actually break when install scripts are disabled?
Three families: native addons compiled by node-gyp at install time, packages that download a binary in a postinstall step such as puppeteer or cypress, and developer tooling that wires itself into the repository like husky. Most other dependencies declare no install script at all, and modern packages increasingly ship prebuilt platform binaries as optional dependencies instead.
Is ignore-scripts=true in .npmrc enough on its own?
It is enough for npm, but only if the file is actually read. A project .npmrc applies when npm runs with the repository as its working directory, so a job that installs from a different prefix, a container image that copies only package.json, or a different package manager will all miss it. Pin the manager and pass the flag explicitly in CI as a belt-and-braces measure.
How do I keep husky working with scripts disabled?
Husky installs git hooks from a prepare script, which --ignore-scripts skips. Run npm run prepare as an explicit step after installing on developer machines. In CI you usually want the opposite outcome: git hooks have no purpose in a non-interactive job, so leaving prepare unrun is the correct behaviour rather than a regression to fix.
Does disabling install scripts stop a malicious package entirely?
No. It removes the easiest and most automated execution path, the one that fires the moment a tarball is extracted. Malicious code inside the package’s actual modules still runs the first time your application or test suite imports it, and the tarball is still written to disk regardless. Treat the flag as one control among several, not as containment.
Related
Permalink to "Related"- Enforcing Package Manager Versions with Corepack — pinning the manager so the script policy above cannot be bypassed by installing with a different tool
- Configuring a Private npm Registry Proxy — controlling which packages can reach your builds in the first place
- Verifying Sigstore Provenance for npm Packages — proving a tarball came from the repository and workflow it claims, before you rebuild it