# Install Scripts (preinstall, postinstall)

> An install script is code a package manager runs automatically while installing a package, such as an npm postinstall script or a Python setup.py.

Also known as: postinstall script, preinstall script, npm install scripts, lifecycle scripts, install hooks, postinstall hook, build scripts

Source: https://socket.dev/glossary/postinstall-scripts

Last updated: 2026-11-02

An install script (also called a lifecycle script or install hook) is code a package manager runs automatically during installation: an [npm](https://socket.dev/glossary/npm-node-package-manager) preinstall or postinstall script, an implicit node-gyp build from binding.gyp, or a Python [setup.py](https://socket.dev/glossary/setup-py-python). It runs with the installing user's privileges, secrets, and network access, which is why most npm supply chain malware executes there.

We think default-on install scripts were a design mistake, and in 2026 the JavaScript ecosystem agreed. [npm v12](https://socket.dev/blog/npm-12), released on July 8, 2026, skips dependency install scripts unless the project approves them; [pnpm](https://socket.dev/glossary/pnpm), Yarn, and Bun already did. The attack surface did not disappear. It moved to approval lists, to the scripts of the project you cloned, to Python builds that have no approval gate, and to startup files that run on the next interpreter start instead of during install.

## What is a postinstall script in package.json?

A postinstall script is a shell command in the `scripts` field of [package.json](https://socket.dev/glossary/package-json) that npm runs after it unpacks that package into `node_modules`. It is one of three install scripts npm recognizes by name:

```json
{
  "name": "hook-demo",
  "version": "1.0.0",
  "scripts": {
    "preinstall": "node hook.js",
    "install": "node hook.js",
    "postinstall": "node hook.js"
  }
}
```

Two more paths run code at install time. The first is `prepare`, which runs when a package is installed from git or a local directory, because npm has to build it before it can pack it. The second has no name in the manifest at all: [npm's scripts documentation](https://docs.npmjs.com/cli/v12/using-npm/scripts) says that when a package root contains `binding.gyp` and defines no `install` or `preinstall`, npm defaults `install` to `node-gyp rebuild`.

Install scripts exist because some packages cannot run as published. Native addons such as sharp, better-sqlite3, and bcrypt compile C++ against the local Node.js headers. Tools such as esbuild, Playwright, Cypress, and Electron download a platform-specific binary. Those cases are rare. When [npm RFC #488](https://github.com/npm/rfcs/pull/488) counted the registry, 12,035 of 1,996,434 packages, about 0.6%, declared any install script, and 6,989 of those used postinstall.

To see what an install script can reach, we packed the manifest above with a `hook.js` that prints its own context, then installed the tarball with npm 10.9.9 and an `NPM_TOKEN` in the environment:

```bash
$ NPM_TOKEN=secret npm install ../pkg/hook-demo-1.0.0.tgz --foreground-scripts
[preinstall] cwd=/work/app/node_modules/hook-demo INIT_CWD=/work/app pkg=hook-demo@1.0.0 script="node hook.js" NPM_TOKEN=visible
[install] cwd=/work/app/node_modules/hook-demo INIT_CWD=/work/app pkg=hook-demo@1.0.0 script="node hook.js" NPM_TOKEN=visible
[postinstall] cwd=/work/app/node_modules/hook-demo INIT_CWD=/work/app pkg=hook-demo@1.0.0 script="node hook.js" NPM_TOKEN=visible

added 1 package, and audited 2 packages in 430ms
```

Three details in that output matter. The script runs from inside `node_modules/hook-demo`, but `INIT_CWD` hands it the path of the project that ran the install. It inherits the entire parent environment, so publish tokens, cloud keys, and CI secrets are readable. And the output above exists only because we passed `--foreground-scripts`. With default flags, the same install printed nothing but `added 1 package`, so a script that exfiltrates a token looks identical to one that does nothing.

## When does an npm postinstall script run?

An npm postinstall script runs after every dependency in the tree has been extracted and after every dependency's preinstall and install scripts have finished. npm runs install scripts as phases across the whole tree, not package by package. We built a project with two dependencies that each declare all three scripts and recorded the order under npm 12.2.1:

> Timeline of one bare npm install under npm 12. Step 1: the root project's preinstall runs. Step 2: npm resolves, fetches, and extracts every tarball; no package code runs. Steps 3 to 5: every dependency's preinstall, then every install (or node-gyp rebuild when binding.gyp exists), then every postinstall, each run as one phase across the tree; npm 12 skips these unless approved in allowScripts. Step 6: the root project's install, postinstall, and prepare run.

The figure shows six steps for one bare `npm install` under npm 12.2.1, in this order:

| Step | Whose code runs | npm 10.9.9 default | npm 12.2.1 default |
| --- | --- | --- | --- |
| 1. preinstall | Your project's package.json | Runs, but after steps 3 to 5 | Runs, before any tarball is fetched |
| 2. Resolve, fetch, extract | No package code | Not applicable | Not applicable |
| 3. preinstall | Every dependency | Runs | Skipped unless approved in allowScripts |
| 4. install, or node-gyp rebuild when binding.gyp exists | Every dependency | Runs | Skipped unless approved in allowScripts |
| 5. postinstall | Every dependency | Runs | Skipped unless approved in allowScripts |
| 6. install, postinstall, prepare | Your project's package.json | Runs | Runs |

Inside steps 3 to 5, each script's working directory is `node_modules/<pkg>`, `INIT_CWD` points at your project, the full parent environment is inherited, and output is hidden unless `--foreground-scripts` is set. One more rule changes what runs: `npm install <pkg>` with a package argument skips your project's own install scripts in both versions. Only a bare `npm install` or `npm ci` runs steps 1 and 6.

npm knows which packages have install scripts before it runs anything, and it writes that knowledge down. The registry's abbreviated metadata, which installers request with `Accept: application/vnd.npm.install-v1+json`, carries a boolean `hasInstallScript`, and [package-lock.json](https://socket.dev/glossary/package-lock-json) copies it into every entry:

```json
"node_modules/hook-demo": {
  "version": "1.0.0",
  "resolved": "file:../pkg/hook-demo-1.0.0.tgz",
  "integrity": "sha512-0QFi2rpIURUbix1+6W7zVWcKKOve0PVzwA7lrqzhjjhgmZTtySAas8C5/OL8vSUJ11IALt53oOul/EbG4ZTXnQ==",
  "hasInstallScript": true
}
```

The registry computes that flag from the manifest the publisher uploads, not from the tarball npm later extracts. When the two disagree, different installers run different scripts, a split we cover under [manifest confusion](https://socket.dev/glossary/manifest-confusion).

## Why install time is the execution point attackers prefer

Install time is the earliest moment a malicious package controls a machine, and it needs nothing from the victim's code. The package never has to be imported. A dependency five levels deep runs exactly like a direct one. The machine is often a CI runner holding publish tokens, or a developer laptop holding SSH keys and cloud credentials. The campaigns that defined the past two years used it:

- The [Rspack supply chain attack](https://socket.dev/blog/rspack-supply-chain-attack) in December 2024 ran cryptomining malware from a postinstall script. pnpm's maintainer cited it when [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default) stopped running dependency scripts by default.
- [Shai-Hulud](https://socket.dev/blog/ongoing-supply-chain-attack-targets-crowdstrike-npm-packages), first observed on September 14, 2025, added `"postinstall": "node bundle.js"` to compromised packages, stole tokens, and republished itself as a [worm](https://socket.dev/glossary/worm).
- [Shai-Hulud v2](https://socket.dev/blog/shai-hulud-strikes-again-v2) moved to preinstall in November 2025. For each stolen npm token, it fetched up to 100 of the maintainer's packages, injected `setup_bun.js`, added the preinstall script, bumped the patch version, and republished.
- In the [Mastra compromise](https://socket.dev/blog/mastra-npm-packages-compromised) of June 2026, the 140+ affected @mastra/\* versions contained unmodified code. The payload arrived through an injected dependency, easy-day-js, whose postinstall script ran during `npm install`.

None of those packages had a CVE when its install script executed. Malware does not get one, which is why CVE scanning alone misses this entire class of attack.

### The axios dropper erased its own install script

The [axios compromise](https://socket.dev/blog/axios-npm-package-compromised) shows how little evidence an install script needs to leave. On March 30, 2026, at 23:59:12 UTC, an attacker published plain-crypto-js@4.2.1, then poisoned axios@1.14.1 and axios@0.30.4 within 39 minutes of each other to depend on it. Its postinstall ran `node setup.js`, which fetched a platform-specific payload, deleted `setup.js`, deleted `package.json`, and renamed a bundled `package.md`, a clean 4.2.0 manifest, to `package.json`.

We rebuilt that cleanup step with a benign package and installed it with npm 10.9.9. The script wrote a marker file, then swapped its own manifest:

```bash
$ npm install ../swap/swap-demo-4.2.1.tgz --silent
$ cat /tmp/swap_payload_ran
yes
$ cat node_modules/swap-demo/package.json
{ "name": "swap-demo", "version": "4.2.0" }
$ grep -A5 '"node_modules/swap-demo"' package-lock.json
    "node_modules/swap-demo": {
      "version": "4.2.1",
      "resolved": "file:../swap/swap-demo-4.2.1.tgz",
      "integrity": "sha512-wmofwCM2lXtJzJU5Tdb238DxxY2gMlQym4oG4JBiP+0NAuwyeYf4pOu1MiFdubg5KqGs9jcffONwQ9CKnnQDOw==",
      "hasInstallScript": true
$ npm ls swap-demo
app@1.0.0 /work/app-swap
`-- swap-demo@4.2.1
```

Anyone auditing `node_modules` afterward sees version 4.2.0 with no scripts. The lockfile still records 4.2.1 and `hasInstallScript: true`, so a project install leaves one honest witness. That witness does not exist everywhere. Code fetched by `npx` or a global install never appears in a project lockfile, which is why [the hidden blast radius of the axios compromise](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) is hard to measure after the fact.

## How do attackers run code at install time without a postinstall script?

Attackers run install-time code without a postinstall script by using triggers that reviewers and scanners do not read as scripts. The scripts field is the first place anyone looks, so the more careful campaigns have left it empty.

### binding.gyp: the install script with no name

The [Miasma wave of Mini Shai-Hulud](https://socket.dev/blog/miasma-mini-shai-hulud-hits-immobiliarelabs-npm-packages) shipped a `binding.gyp` in packages that had no native code, byte-identical across 22 compromised versions of four ImmobiliareLabs packages. gyp evaluates each `conditions` entry as a Python expression with builtins removed. The [openapi-react-query-codegen compromise](https://socket.dev/blog/openapi-react-query-codegen-npm-compromise) published one that walks Python's class hierarchy to get them back:

```json
"conditions":[
  ["[c for c in ().__class__.__base__.__subclasses__() if c.__name__ == u'\\U00000063...'][0]()._module.__builtins__[u'\\U0000005f...'](u'\\U0000006f...').system(u'\\U0000006E\\U0000006F\\U00000064\\U00000065\\U00000020\\U00000033\\U00000046\\U00000057\\U00000043\\U00000076\\U0000007A\\U00000064\\U00000075\\U00000059\\U0000005A\\U00000067\\U0000002E\\U0000006A\\U00000073') == 0x00", {}]
]
```

The escapes decode to `catch_warnings`, `__import__`, `os`, and the command `node 3FWCvzduYZg.js`, a 5.6 MB payload. All ten malicious versions of that package carried valid npm provenance from the project's own trusted publishing workflow. Provenance proved which pipeline built the tarball. It said nothing about what the tarball would do.

You do not need the Python trick to see the trigger. We packed a package whose `package.json` declares no scripts and whose `binding.gyp` is `{ "targets": [] }`. npm 10.9.9 ran `node-gyp rebuild` anyway, and npm 12 reports the synthesized command when it blocks it:

```bash
$ npm install ../gyp/gyp-demo-1.0.0.tgz --foreground-scripts   # npm 10.9.9
> gyp-demo@1.0.0 install
> node-gyp rebuild

$ npm install ../gyp/gyp-demo-1.0.0.tgz                        # npm 12.2.1
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   gyp-demo@1.0.0 (install: node-gyp rebuild)
```

### Your own project's scripts still run

`allowScripts` governs dependencies, not the project you cloned. We confirmed that npm 12.2.1 runs a root `postinstall` on a bare `npm install` without asking. That gap is how [the malicious postinstall script planted in Packagist-tracked repositories](https://socket.dev/blog/malicious-postinstall-hook-found-across-700-github-repos) reached victims: for a starter kit, the poisoned `package.json` is the project root, so `npm install` runs it directly. Before v12, git dependencies had a related hole: [a git dependency's .npmrc could override the git executable](https://socket.dev/blog/npm-12), which ran code even under `--ignore-scripts`. npm 12 refuses git dependencies unless `--allow-git` permits them.

### Python runs setup.py before you decide to install

A setup.py is Python's oldest install script, and pip runs it to learn what a source distribution is. We built a source distribution whose `setup.py` writes a marker file, then asked pip 26.2.2 to download it and to dry-run an install. Neither command installs anything. Both executed the file:

```bash
$ pip download --no-cache-dir --find-links dist --no-deps setup-demo -d dl -v
  [setup.py] executing at build time, argv=['egg_info'] HOME=/root
  [setup.py] executing at build time, argv=['dist_info', '--output-dir'] HOME=/root
Saved ./dl/setup_demo-1.0.0.tar.gz

$ pip install --no-cache-dir --dry-run --find-links dist setup-demo -v
  [setup.py] executing at build time, argv=['egg_info'] HOME=/root
  [setup.py] executing at build time, argv=['dist_info', '--output-dir'] HOME=/root
Would install setup-demo-1.0.0
```

`pip install --only-binary=:all:` refused the same package because it has no wheel, and `uv pip compile --no-build` failed the same way, which is the point: neither will build from source. One caveat surprised us. Passing pip an explicit path to the `.tar.gz` built it despite `--only-binary=:all:`, because a direct path is not resolved from an index.

Wheels never run code at install, but they can still plant code that runs moments later. A `.pth` file in `site-packages` executes its `import` lines on every interpreter start, as the [Python site documentation](https://docs.python.org/3/library/site.html) describes, and that includes the next `pip` invocation. The [LiteLLM compromise](https://socket.dev/blog/litellm-compromise) on October 21, 2026 shipped `cook.pth`, whose entire contents are `import cook`, inside litellm 1.107.0 wheels. Python 3.15 deprecates `import` lines in `.pth` files but still executes them.

## How do npm, pnpm, Yarn, and Bun handle install scripts in 2026?

As of November 2026, the current releases of npm, pnpm, Yarn, and Bun all skip dependency install scripts by default. They differ in what happens to an unreviewed script and in how long an approval lasts. We tested each with esbuild 0.25.0, whose postinstall runs `node install.js`:

| Package manager | Default for dependency scripts | Unreviewed script | Where approvals live | What an approval covers |
| --- | --- | --- | --- | --- |
| npm 12.2.1 | Skipped | Warning; install succeeds. strict-allow-scripts=true makes it an error | allowScripts in package.json | One version: approve wrote "esbuild@0.25.0": true |
| npm 10 and 11 | Run | Runs, output hidden | None; --ignore-scripts is all or nothing | Not applicable |
| pnpm 12.16.1 | Skipped | Install fails with ERR\_PNPM\_IGNORED\_BUILDS | allowBuilds in pnpm-workspace.yaml | Any version: approve-builds wrote esbuild: true |
| Yarn 4.18.1 | Skipped since 4.14.0 (enableScripts: false) | Warning YN0004 | enableScripts in .yarnrc.yml | A project-wide switch |
| Bun 1.4.4 | Skipped, except for a curated default list; esbuild is on it, so its postinstall ran unprompted | Blocked, with a one-line notice | trustedDependencies in package.json | Package names; the field replaces the default list |
| Verdict | In CI, use npm 12 with strict-allow-scripts=true or pnpm 12's default | A new install script should fail the build, not print a warning | Commit approvals with the code | Prefer npm's version-pinned approvals |

Here is npm 12's flow end to end. The install succeeds with a warning, the approval command writes a version-pinned entry, and an upgrade to a new esbuild version puts it back in front of a reviewer:

```bash
$ npm install esbuild@0.25.0
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   esbuild@0.25.0 (postinstall: node install.js)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

$ npm install-scripts approve esbuild
Approved esbuild:
  added esbuild@0.25.0

$ cat package.json
{"name":"app","version":"1.0.0","dependencies":{"esbuild":"^0.25.0"},"allowScripts":{"esbuild@0.25.0":true}}
```

Pinning is the property that matters most. pnpm 12's `approve-builds` wrote `esbuild: true`, and Bun's `trustedDependencies` takes names. With a name-level approval, a compromised next release of esbuild runs its install script on the first install that resolves it. Bun goes further: esbuild is on its default list, so every Bun user has approved it without being asked. npm's [install-scripts documentation](https://docs.npmjs.com/cli/v12/commands/npm-install-scripts) states the design intent: pinned entries "keep their approval narrowed to the specific version you reviewed." The Yarn result comes from our own tests: 4.13.0 reports `enableScripts` as `true`, 4.14.0 and every later release we tried report `false`, and the [Yarn configuration reference](https://yarnpkg.com/configuration/yarnrc) now documents `false` as the default. [pnpm 11](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) replaced `onlyBuiltDependencies` with `allowBuilds`, and pnpm's [build settings reference](https://pnpm.io/settings/build) documents `strictDepBuilds` as `true` by default, which is why an unreviewed build fails the install. Bun's [lifecycle documentation](https://bun.com/docs/install/lifecycle) says defining `trustedDependencies` replaces its default list rather than extending it.

Before npm's change, pnpm contributor Brandon Cheng summed up the case on the pnpm pull request that turned scripts off, as quoted in [pnpm 10.0.0 Blocks Lifecycle Scripts by Default](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default): "It's nuts that a \[pnpm\|yarn\|npm\] install can run arbitrary code in the first place."

## What turning off install scripts does not fix

Blocking dependency install scripts removes automatic execution at install time. It does not remove execution, and each mitigation has a cost and a gap:

- **Code still runs at import time.** A malicious package does not need an install script if its payload sits in its main module. Blocking install scripts only delays execution until the first `require()` or `import`, which a test suite or build performs minutes later. The bioinformatics wheels in [the Mini Shai-Hulud, Miasma, and Hades wave on PyPI](https://socket.dev/blog/mini-shai-hulud-miasma-and-hades-worms-target-bioinformatics-and-mcp-developers-via-malicious) executed from a native extension at import time.
- **Blocking has a runtime cost.** A skipped node-gyp build does not fail the install. It fails at runtime when code loads a module that was never compiled, so teams under deadline approve broadly. npm 12's review command shows the command a script runs, not what the script does.
- **Provenance does not vet the script.** The openapi-react-query-codegen versions above carried valid provenance. After a [maintainer account takeover](https://socket.dev/glossary/maintainer-account-takeover) or a hijacked release workflow, everything the attacker publishes comes from the legitimate pipeline.
- **Python has no approval list.** pip and uv will build any source distribution they resolve. Binary-only mode avoids `setup.py`, but it fails on every package without a wheel for your platform and does nothing about `.pth` files.

The presence of an install script is weak evidence on its own. Socket's [Install scripts alert](https://socket.dev/alerts/installScripts) flags packages with install scripts, and in 2024 we [changed its default policy action from Block to Ignore](https://socket.dev/blog/announcing-new-default-security-policies) because it was too noisy to block on. We analyze the code inside the package tarball, not its metadata, and raise separate alerts for what that code does, such as Shell access, Network access, and Environment variable access. [Socket Firewall Free](https://docs.socket.dev/docs/socket-firewall-free) acts before any install script exists on disk: it wraps npm, pnpm, Yarn, pip, uv, and cargo, and blocks the network request for a package artifact that our team has confirmed as malware. It does not block unreviewed or unscanned versions, and it does not check packages already in a local cache.

The 2026 defaults turned install-time execution from an ambient capability into a granted one. The next attacks will target the grant. Every `allowScripts`, `allowBuilds`, or `trustedDependencies` entry is pre-authorized code execution for whoever controls that package next, and the packages most likely to be approved, the native and binary-downloading ones, are the most valuable to compromise. Until an approval binds to the reviewed script content and what it does, rather than to a name or a version string, a human can only approve the past.
