Skip to content
PreClone

What npm install runs: postinstall, prepare and the scripts nobody reads

Guides · Packages and install scripts · Published 20 October 2026

A bare npm install in a repo runs that repo's own preinstall, install, postinstall, prepublish, preprepare, prepare and postprepare scripts from package.json, in that order, as you. Your dependencies' install scripts are a separate question: npm 12, pnpm, Yarn and Bun now hold them back by default, but the list of exceptions lives in the repo you're installing. So read package.json, .npmrc and the lockfile before you type the command.

Most pages about postinstall are about third-party packages. This one starts with the repo someone sent you, the take-home or the client project, because its scripts are the ones nothing blocks. It's the install-time part of checking a repo before you clone it.

What npm install runs, in order

npm's docs list the order for a bare npm install in a local project, meaning no package name after it (npm scripts docs):

  1. preinstall, before any dependency is fetched. Since npm 12 it runs before resolution begins, so it can't rely on anything in node_modules (npm 12 changelog).
  2. install and postinstall, after the dependencies are in place.
  3. prepublish (deprecated, but it still runs on npm install and npm ci).
  4. preprepare, prepare, postprepare.

A few rules change what that list means in practice:

  • npm ci runs the same scripts, so a README that says npm ci instead changes nothing.
  • npm install some-package doesn't run the project's prepare. Those scripts are tied to the bare command.
  • If the repo has a binding.gyp and no install or preinstall script, npm runs node-gyp rebuild as the install script anyway.
  • In a workspace, prepare runs in every package, concurrently, so read every package.json, not just the root.
  • You won't see them run. Since npm 7 these scripts run in the background, and their output only shows with --foreground-scripts.
  • A dependencies script runs after any command that changes node_modules, which includes a later npm install some-package.

npm's own advice to package authors is that you should almost never need a preinstall or install script, and that the only valid use is compiling for the target machine (npm scripts docs). For anything else it points authors to prepare. An app repo, rather than a native module, rarely needs any install script at all, so each one you find needs a reason.

Your dependencies' scripts: off by default, unless the repo says otherwise

For most of npm's history, an install ran the install scripts of every package in the tree. Every major package manager has now changed that. Each keeps an allow list, and in every case the allow list is a file in the project, which means the repo you're about to install wrote it.

Package managerDependencies' install scriptsWhere the allow list lives
npm 12 (8 July 2026)Blocked unless allowed; npm prints the ones it skipped (npm install-scripts)allowScripts in the root package.json
npm 11Run. Recent 11.x releases read allowScripts but only warn: unreviewed scripts run with a notice (npm 11 config)allowScripts in package.json
pnpm 10 and laterBlocked unless allowed (pnpm 10 release); since 11, an unreviewed one fails the install (pnpm build settings)allowBuilds in pnpm-workspace.yaml (it replaced onlyBuiltDependencies in pnpm 11, release notes)
Yarn 4.14 and laterNot run: enableScripts became false by default (Yarn 4.14.0, yarnrc docs)enableScripts in .yarnrc.yml, dependenciesMeta in package.json
BunNot run, except a built-in list of npm packages (Bun docs)trustedDependencies in package.json

Two things follow from that table.

The repo's own scripts aren't on any list. npm's policy covers dependencies. pnpm documents ignoreScripts as the setting for “the project package.json and its dependencies”, separate from allowBuilds (pnpm). Yarn says workspaces “will still see their postinstall scripts evaluated” (yarnrc docs). Bun's page only describes dependencies. A malicious take-home doesn't need a malicious dependency; its own postinstall is enough.

An allow list in a stranger's repo is the stranger's choice. If the repo adds a package to allowScripts or allowBuilds, or sets enableScripts: true, that package's install script runs. pnpm's dangerouslyAllowAllBuilds: true runs every dependency build that isn't explicitly denied (pnpm), and npm's dangerously-allow-all-scripts runs every one, approved or denied (npm config). Read those settings like you'd read a script.

Git, tarball and URL dependencies

A dependency that points at a git repo is built on your machine. When a package installed from git has a prepare script, npm installs its dependencies and dev dependencies and runs prepare before packing it (npm scripts docs). npm 12 counts that prepare as an install script under allowScripts, and it also stopped fetching git dependencies, and tarball URLs on hosts other than your registry, unless you opt in: allow-git and allow-remote now default to none (npm config). npm's docs tell you to opt back in per project, in .npmrc, which again is a file the repo can ship. pnpm 11 and later block git and tarball sources for transitive dependencies with blockExoticSubdeps (pnpm).

Dependency names matter too. A name one letter off a real package, or one an AI assistant made up, is how a bad package gets into an otherwise ordinary package.json; that's slopsquatting. What npm audit does and doesn't catch here is in npm audit and malware.

Read them before you install

All of this is plain text. Read it from the downloaded archive, before any package manager touches it.

# the scripts, in every package.json outside node_modules
find . -name package.json -not -path '*/node_modules/*' \
  -exec sh -c 'echo "== $1"; jq ".scripts // {}" "$1"' _ {} \;

# the allow lists and package manager settings in the root
jq '{allowScripts, trustedDependencies, dependenciesMeta, pnpm, packageManager}' package.json

# config files that change what install runs (next section)
ls -la .npmrc .npm-extension.* .yarnrc.yml .yarnrc .pnpmfile.* pnpm-workspace.yaml bunfig.toml 2>/dev/null

In each scripts block, look at the seven install-time names above. Routine ones run a known tool: husky, prisma generate, tsc, node-gyp rebuild. The ones to stop at run a file in the repo, especially one in public/, assets/ or scripts/, call curl or wget, or pipe anything into sh. Then open the file it runs.

The lockfile

package-lock.json records, for each package, hasInstallScript when it has a preinstall, install or postinstall script, and resolved: the tarball URL for a registry package, or the full git URL with commit for a git one (package-lock.json docs). Two queries:

# packages that declare an install script
jq -r '.packages | to_entries[] | select(.value.hasInstallScript) | .key' package-lock.json

# packages that don't come from the npm registry
jq -r '.packages | to_entries[]
  | select(.value.resolved and (.value.resolved | startswith("https://registry.npmjs.org/") | not))
  | "\(.key)  \(.value.resolved)"' package-lock.json

A private registry or a git dependency can be legitimate. A raw IP address, a file-sharing host or a GitHub account that isn't the project's isn't. The lockfile is also the author's file, so treat it as a description of their install, not proof of yours. For yarn.lock, pnpm-lock.yaml and bun.lock, a grep for git+, github: and http does the rough version of the same job.

Once you've installed with scripts off (below), npm can list what would have run from the real tree. npm query ":attr(scripts, [postinstall])" lists installed packages with a postinstall, and npm query ":type(git)" lists git dependencies (npm query). On npm 12, npm install-scripts ls lists the packages whose install scripts aren't covered by allowScripts yet (npm install-scripts).

Config files that change what install runs

A repo doesn't need a postinstall to run code during install. These files sit next to package.json and the package manager reads them first.

  • .npmrc. npm reads the .npmrc at the root of the project you run it in (npmrc docs). node-options is passed to Node through NODE_OPTIONS and affects how lifecycle scripts are called (npm config), so node-options=--require ./x.js loads x.js into the Node processes npm's scripts start, npm start included. Also look for registry pointing somewhere other than registry.npmjs.org, script-shell, ignore-scripts=false, allow-git and dangerously-allow-all-scripts.
  • .npm-extension.mjs or .npm-extension.cjs (npm 12). A JavaScript file at the project root that npm imports during install so it can rewrite dependency manifests before resolution (npm docs). It's code that runs on install without being in scripts. --ignore-scripts skips it.
  • .yarnrc.yml yarnPath. Yarn runs the file yarnPath names instead of your installed Yarn, “for any command run within the directory” (yarnrc docs). That includes yarn --version. A committed release in .yarn/releases/ is normal; a yarnPath pointing anywhere else is a script with a different name.
  • .pnpmfile.mjs or .pnpmfile.cjs. pnpm loads it next to the lockfile and calls its hooks during install (pnpm docs). pnpm's ignoreScripts doesn't stop it (pnpm build settings); its separate ignorePnpmfile setting does (pnpm docs).
  • pnpm-workspace.yaml. Since pnpm 11, everything except auth and registry is configured here, not in .npmrc (pnpm settings): allowBuilds, dangerouslyAllowAllBuilds, nodeOptions and scriptShell.

Install isn't the last step. npm run dev loads next.config.js, postcss.config.js and the rest, and those can carry a payload with clean install scripts; that's malware in build configs. Python has the same problem in setup.py, covered in what pip install runs.

--ignore-scripts and what it misses

npm install --ignore-scripts tells npm not to run scripts from package.json files: the repo's and every dependency's. On npm 12 it also skips a .npm-extension file (npm config). What it doesn't stop:

  • npm start, npm test, npm stop, npm restart and npm run still run the script you asked for. They skip its pre and post scripts, nothing more (npm config).
  • node-options from the repo's .npmrc, which still applies when you run that script.
  • The other package managers' equivalents of this config: Yarn's yarnPath, and a pnpmfile under pnpm's own --ignore-scripts.
  • Build configs, editor tasks, agent hooks and git hooks, which aren't npm scripts.

It also changes what you get. A prepare that runs husky won't install the git hooks, and a package that compiles or downloads a native binary on install won't work until you build it. Read its script, then run npm rebuild <pkg>; npm documents that command for exactly this, after an install with --ignore-scripts (npm rebuild). If you've set ignore-scripts in your config, add --ignore-scripts=false to that one command.

Make it the default

# npm: writes ignore-scripts=true to ~/.npmrc
npm config set ignore-scripts true

# pnpm: per install (read .pnpmfile.* first; this flag doesn't skip it)
pnpm install --ignore-scripts

# Yarn: run no build scripts for this install
yarn install --mode=skip-build

# Bun: in bunfig.toml
[install]
ignoreScripts = true

Three caveats. npm gives a project .npmrc priority over ~/.npmrc (npmrc docs), so a repo can ship ignore-scripts=false and undo your default; check for it, and pass the flag on the command line for a repo you don't know. pnpm 11 and later read only auth and registry settings from .npmrc, so your ~/.npmrc line doesn't reach pnpm (pnpm 11 release notes); --ignore-scripts works on pnpm install (pnpm docs). Yarn documents --mode=skip-build as not running build scripts (Yarn docs), but that can't stop a malicious yarnPath, because that file is Yarn. Yarn's ignorePath setting makes it keep running your own Yarn instead (yarnrc docs).

A release-age gate is a different control. npm's min-release-age only installs versions older than the number of days you give it (npm config), and pnpm 11 waits a day by default, though that built-in default falls back to a newer version when nothing in range is old enough (pnpm 11). That protects you from a package that was compromised yesterday. It does nothing about a repo whose own postinstall is the payload. The settings for each package manager are in npm min-release-age.

A real one: a postinstall committed to the repo

In May 2026 Socket reported a campaign that committed a postinstall hook straight into the package.json of other projects' repositories, Laravel starter kits among them (Socket, 22 May 2026). The hook used curl -k, with certificate checks off, to download a binary from GitHub Releases, saved it as /tmp/.sshd, made it executable and ran it in the background with errors hidden. Socket confirmed malicious commits in eight Packagist packages, and a GitHub code search for the attacker's account returned hundreds more results it hadn't yet sorted into real compromises, forks and copies.

Socket's own analysis makes the point of this guide: for a library installed into vendor/, a top-level npm install wouldn't run the hook. The starter kits were the risk, because there the package.json is the root of your project. The dependency allow lists in the table above don't apply to a script like this: it was the project's own. --ignore-scripts, or reading the scripts block first, would have stopped it.

What a check shows

I built PreClone to do the reading above before the repo is on your disk. For install it lists the install scripts in the root and workspace package.json files and follows each into the file it runs, reads .npmrc, .yarnrc.yml, pnpmfiles and pnpm's build settings, flags lockfile entries that resolve away from the public registries, and looks dependencies up on npm and OSV for known malware, lookalike names and git or URL sources. The fake take-home example shows a postinstall that runs a “Node version check” which pipes a download into sh, next to a dependency named one letter off jsonwebtoken. The full list is under what it checks at install.

It reads files; it doesn't run them. It can't see what a download returns on the day, the code inside binaries, or your dependencies' own source, which isn't downloaded. It doesn't yet read npm 12's allowScripts, a .npm-extension file, Yarn's enableScripts or Bun's trustedDependencies, so check those by hand with the commands above. The best it will say is “Nothing flagged”.

Questions

What does postinstall do in npm?
It's a script in package.json that npm runs after it has installed a package. When you run a bare npm install in a repo, the repo's own postinstall runs on your machine with your user's permissions, so it can run any command you could.
Does npm install still run my dependencies' install scripts?
Not by default since npm 12, released on 8 July 2026: a dependency's preinstall, install and postinstall only run if the root package.json lists it in allowScripts. npm 11 and earlier run them. Either way, the repo's own scripts still run.
What's the difference between prepare and postinstall?
Both run on a bare npm install in the project. prepare also runs before npm pack and npm publish, and when npm installs a package from git it installs that package's dev dependencies and runs its prepare script first. postinstall runs after the package is installed.
Is npm install safe on a repo I just cloned?
Only as much as the scripts and config in that repo. Read package.json scripts, .npmrc, any .npm-extension file and the lockfile first, or install with --ignore-scripts and read them before you run anything else.
Does pnpm run postinstall scripts?
pnpm runs the project's own lifecycle scripts. Since pnpm 10 it doesn't run dependencies' build scripts unless the project allows them, which pnpm 11 and later do with the allowBuilds setting in pnpm-workspace.yaml.

Check a repo before you open it

Paste a GitHub, GitLab or Bitbucket link. PreClone reads the repo without running it and lists what would run on your machine, by when it runs. Free for public repos, no login. It can miss things, so it never calls a repo safe.

Or see an example report first.