npm run executes a named script defined in the scripts field of your package.json, running it in a shell with your project's local binaries added to the PATH. When you type npm run build, npm looks up the build key under scripts, runs the command it finds there, and makes every executable in node_modules/.bin available without a path prefix. That single mechanism powers most JavaScript build, test, and deploy workflows, and it also carries supply-chain risks that are easy to overlook because the scripts run with your full user permissions.
Understanding exactly what npm run does, including the parts that run without you typing them, is worth a few minutes.
The basics of npm run
Scripts live in package.json:
{
"scripts": {
"build": "webpack --mode production",
"test": "jest",
"start": "node server.js",
"lint": "eslint src/"
}
}
Running npm run build executes webpack --mode production. The key feature is the PATH modification: webpack here does not need to be installed globally, because npm prepends node_modules/.bin to the PATH for the duration of the script. Every locally installed CLI tool becomes directly callable.
A few conveniences are worth knowing. npm run with no argument lists all available scripts. Passing arguments through to the underlying command uses -- as a separator:
npm run test -- --watch
Everything after -- goes to jest, not to npm. And test and start are special: they have shorthands (npm test, npm start) that skip the run word.
Lifecycle scripts run without being asked
This is the part that surprises people and the part that matters most for security. npm recognizes certain script names as lifecycle hooks that fire automatically around other commands.
The pre and post prefixes chain around any script. If you define prebuild, it runs automatically before build; postbuild runs after. You never call them directly.
More importantly, install hooks run when a package is installed. Historically preinstall, install, and postinstall scripts execute automatically during npm install, and here is the critical detail: they run not only for your project but potentially for your dependencies. A malicious or compromised package can define a postinstall script that executes arbitrary code on your machine the moment you install it, with your user's permissions.
This is the core of a whole class of supply-chain attacks. An attacker publishes a package (or hijacks a legitimate one via a compromised maintainer account) whose postinstall script exfiltrates environment variables, steals credentials, or plants a backdoor. Because it runs during npm install, it fires before you ever import or use the package.
Reducing install-script risk
You have real controls here. To install without running lifecycle scripts at all:
npm install --ignore-scripts
For CI environments, many teams set ignore-scripts=true in .npmrc globally and only enable scripts for the specific trusted packages that genuinely need them. It is a blunt instrument, since some legitimate native modules build during postinstall, but for a large share of dependencies it is safe and it closes the single most exploited local-execution path.
Beyond that, pin dependencies with a committed lockfile so an unexpected version cannot slip in, use npm ci instead of npm install in automation (it installs exactly what the lockfile specifies and fails on drift), and scan your dependency tree. An SCA tool such as Safeguard can flag a package that ships install scripts or a known-malicious version, which is hard to notice manually across a tree of hundreds of transitive packages. The SCA product page covers dependency risk detection.
npm publish dry run: check before you ship
When you author packages, the reverse concern applies: you want to know exactly what you are about to publish before it goes public. An npm publish dry run does precisely that:
npm publish --dry-run
This runs the entire publish flow, including packing the tarball and reporting every file that would be included and the total size, but stops short of actually uploading anything to the registry. It is the reliable way to catch a .npmignore mistake that would otherwise leak a .env file, private keys, or internal source into a public package.
You can get the same file listing with npm pack --dry-run, which shows the contents of the tarball npm would create. Running one of these before every publish is a cheap habit that prevents a whole category of accidental disclosure. A leaked credential in a published package is public the instant it uploads, and unpublishing does not un-leak it.
Reading scripts you did not write
Before running npm run <something> in a repository you just cloned, read the script. npm run will happily execute whatever the scripts field says, including a command that downloads and pipes a remote script into a shell. Cloning an untrusted repo and running its build is functionally trusting its authors with code execution on your machine. Open package.json, skim the scripts block, and be especially wary of any preinstall/postinstall entries or commands that fetch and execute remote content.
FAQ
What is the difference between npm run and npm start?
npm start is a shorthand for npm run start. A handful of script names (start, test, stop, restart) have built-in shorthands that let you omit the word run. For every other script name you must use the full npm run <name> form.
Do npm scripts run automatically during install?
Yes. Lifecycle scripts named preinstall, install, and postinstall execute automatically during npm install, including scripts defined by your dependencies. This is a common supply-chain attack vector. You can disable them with npm install --ignore-scripts.
What does npm publish dry run do?
npm publish --dry-run performs the whole publish process, including packing the tarball and listing every file that would be included, but does not upload anything to the registry. It is the standard way to verify you are not about to leak files like .env before publishing a package.
How do I pass arguments to a script run with npm run?
Use -- to separate npm's arguments from the script's. For example, npm run test -- --watch passes --watch to the underlying test command rather than to npm itself.