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.

npm install lifecycle phases An npm install resolves the dependency tree and extracts tarballs, then runs the preinstall, install and postinstall phases; the ignore-scripts flag removes those last three phases, which are the ones that execute code written by package authors. --ignore-scripts removes these three phases resolve dependency tree fetch and extract tarballs preinstall install (node-gyp build) postinstall these phases execute code written by the package author, as your user, before any test or code review has run

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.

Allow-list decision tree For each dependency that declares an install script, ask whether it needs a native addon or platform binary; if not, leave scripts off, and if so, add it to the rebuild allow-list only when the publisher is trusted and the version is pinned. dependency declares an install script needs a native addon or a platform binary? no yes leave scripts off nothing to rebuild publisher trusted and version pinned? no yes replace, vendor, or build in a sandbox add to allow-list, then npm rebuild the package

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.

Package manager script controls compared A four-row matrix comparing npm, pnpm 10 and Yarn 4 on default script behaviour, the global off switch, the per-package allow-list mechanism and the one-off command-line flag. behaviour npm pnpm 10+ Yarn 4 dep scripts default run blocked run global off switch ignore-scripts=true ignore-scripts=true enableScripts: false per-package control npm rebuild <pkg> onlyBuiltDependencies dependenciesMeta one-off CLI flag --ignore-scripts --ignore-scripts --mode=skip-build

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-scripts is not scoped to dependencies. Your root prepare, postinstall and friends are skipped as well, which is why husky stops installing git hooks and why patch-package stops applying patches. Run npm run prepare or npm run postinstall as an explicit follow-up step; scripts invoked through npm run are unaffected by the setting.

  • npm rebuild reads the same config key. With ignore-scripts=true in .npmrc, a plain npm rebuild esbuild completes 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-scripts while 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 audit will never flag an install script. Advisory databases describe known vulnerabilities in known versions; a brand-new package whose only payload is a postinstall has 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: true showing 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.

Permalink to "Related"

Related Articles

Configuring a Private npm Registry Proxy
Enforcing Package Manager Versions with Corepack
Registry & Package Manager Hardening Supply Chain Auditing & Depend…