- check, in order1Current project's own binmatch: run it
package.jsonin the current directory or a selected workspace declares"bin": {"<name>": ...} - no match2node_modules/.bin, walking upmatch: run it
./node_modules/.bin/<name>, then../,../../and every parent up to/ - no match3Global bin directorymatch: run itwhere
npm install -glinks executables - no match4Registry: <name> becomes a package nameinstall, then run
GET registry.npmjs.org/<name>withpreferOnline: true- install into
~/.npm/_npx/<sha512(spec)[0:16]>/ - prompt only if stdin is a TTY and no CI is detected
- run the bin the package declares
npx (npm package runner)
npx is npm's package runner: it executes a package's bin, first installing it from the registry into a cache when no local copy exists.
Also known as npm exec, npm x, npx -y
npx is the package runner that ships with npm, also invoked as npm exec. It runs a command from an npm package. When no local copy exists, npx resolves the package from the registry, installs it and its dependencies into a cache, and executes it. Each such run is an install plus code execution that your lockfile never sees.
Running outside the lockfile is what makes npx a security boundary. npx some-cli hands control of your shell to whatever the registry serves for the name some-cli at that moment, resolved against live version ranges, with your user's credentials and environment. Our position is that every npx call that can reach the registry deserves the scrutiny you would give curl | sh. npm v12, released on July 8, 2026, blocks dependency install scripts by default. That change does not touch the core of npx, because running the package's own code is the whole point of npx.
What is npx?
#npx is a command-line tool, bundled with npm, that executes a package's bin entry without adding the package to your project. npm announced it with npm 5.2.0 on July 11, 2017. The npm CLI documentation says the npx binary was rewritten in npm v7.0.0 as a thin front end for npm exec, and the standalone npx package was deprecated at the same time.
People use npx for three jobs. It runs scaffolders that you need once, such as npx create-vite. It runs a tool without a global install, such as npx prettier --check .. It runs a binary that is already in the project's node_modules/.bin, without typing the path.
The 2017 announcement promised that "when it's done, the installed package won't be anywhere in your globals." That is still true, but it is widely misread as "npx deletes the package afterward." Modern npx keeps every package it installs in the npm cache, under ~/.npm/_npx/, with a full node_modules tree and its own lockfile:
$ npx cowsay@1.6.0 moo </dev/null
npm warn exec The following package was not found and will be installed: cowsay@1.6.0
...
$ ls ~/.npm/_npx/
e3e1d528ee105f80
$ ls ~/.npm/_npx/e3e1d528ee105f80/
node_modules package-lock.json package.json
$ cat ~/.npm/_npx/e3e1d528ee105f80/package.json
{
"dependencies": { "cowsay": "^1.6.0" },
"_npx": { "packages": [ "cowsay@1.6.0" ] }
}We ran this with npm 12.2.1, the current latest release as of October 2026. The directory name is not random. It is the first 16 hex characters of a SHA-512 hash of the sorted package specs, as the libnpmexec source shows. You can reproduce it:
$ printf 'cowsay@1.6.0' | sha512sum | cut -c1-16
e3e1d528ee105f80
$ printf 'cowsay' | sha512sum | cut -c1-16
8f497369b2d6166eSo npx cowsay and npx cowsay@1.6.0 install into different directories, and a cleaned project leaves those trees behind. If you need to know what a machine has executed through npx, ~/.npm/_npx/*/package.json is the first place to look.
What is the difference between npx and npm?
#npm installs packages and npx runs them. npm install records a dependency in package.json, pins the resolved tree in package-lock.json, and writes it to node_modules. npx writes nothing to your project. It finds or fetches a package, puts its bin directory on PATH, and executes one command.
| npm install <pkg> | npx <pkg> | |
|---|---|---|
| Writes to your project | package.json, package-lock.json, node_modules | Nothing. Installs into ~/.npm/_npx/<hash>/ |
| Code it runs by default in npm v12 | No dependency install scripts until you approve them | The package's bin, every time |
| Pinned by your lockfile | Yes, after the first install | No, unless the package is already a local dependency |
| Repeatable on another machine | Yes, with npm ci | No. Resolution depends on registry state at run time |
The second row is the security difference in one line. npm install is a download that may or may not run code. npx is a download that always runs code.
How does npx decide what to run?
#npx resolves a command name through four places in a fixed order, and only the last one touches the registry. We read the order from the libnpmexec source that ships with npm 12.2.1:
- The current project's own
bin. If thepackage.jsonin the current directory, or a selected workspace, declares abinwith that name, npx runs it. node_modules/.bin, walking up the directory tree. npx checks./node_modules/.bin/<name>, then the same path in each parent directory, and stops at the filesystem root.- The global bin directory. npx checks the directory where
npm install -glinks executables. - The registry. If nothing matched, npx treats the command name as a package name. It fetches the package manifest from
registry.npmjs.orgwithpreferOnline: trueand installs the package into~/.npm/_npx/<hash>/, where the hash is the first 16 hex characters of a SHA-512 of the spec. It prompts only if standard input is a TTY and no CI environment is detected, then runs thebinthe package declares.
Two details in that order deserve attention. Step 2 does not stop at the project root, so a stray node_modules/.bin in your home directory answers any npx call beneath it that does not find the command closer. Step 4 converts a command name into a package name, and the rest of this article follows from that conversion.
npx also contacts the registry when the package is already cached. A spec with no version, such as npx cowsay, revalidates the package metadata on every run, and the libnpmexec source installs a newer version into the cache when one exists. We watched it happen with --loglevel=http on a warm cache:
$ npx -y --loglevel=http cowsay hi </dev/null
npm http fetch GET 200 https://registry.npmjs.org/cowsay 53ms (cache revalidated)An unversioned npx call is therefore a standing subscription to the newest release of that package. If a maintainer account is taken over and a malicious version becomes latest, the next run executes it.
How npx turns a command name into a package name
#The name of a package's bin and the name of the package are independent. The typescript package exports a tsc binary. Inside a project that depends on typescript, npx tsc finds node_modules/.bin/tsc at step 2. Outside such a project, step 4 installs the package named tsc, which is a different package:
$ npx tsc --version </dev/null
npm warn exec The following package was not found and will be installed: tsc@2.0.4
npm warn deprecated tsc@2.0.4: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info.
This is not the tsc command you are looking for
To get access to the TypeScript compiler, tsc, from the command line either:
- Use npm install typescript to first add TypeScript to your project before using npx
- Use yarn to avoid accidentally running code from un-installed packagesThe tsc name happens to be held by a benign placeholder, published in January 2022, that prints a warning. Other mismatched names may have no such guardian. Scoped packages make the gap systematic: a bin cannot contain a slash, so @acme/deploy-tools might export acme-deploy, and the unscoped name acme-deploy may be unclaimed. Any README, CI job, or package.json script that says npx acme-deploy works on the author's machine and installs a stranger's package everywhere else. This is typosquatting without the typo.
The attack is practical. Researchers at Depi Security describe finding a package.json script that ran npx against an unregistered bin name during a January 2024 bug bounty event. They registered the name, demonstrated code execution, and later received a $30,000 bounty. They presented the technique at DEF CON 33.
The earlier steps have their own trust problem. Our 2022 research on npm bin script confusion showed that any package in your tree can link an executable named node or npm into node_modules/.bin, and npm considers that working as designed. Because step 2 runs before anything else, a malicious transitive dependency can shadow the command you meant to run with npx.
Does the npx prompt protect you?
#The npx install prompt protects only a human at an interactive terminal. The npx documentation explains the prompt's purpose: "To prevent security and user-experience problems from mistyping package names, npx prompts before installing anything." At a terminal, npm 12.2.1 prints:
Need to install the following packages:
cowsay@1.5.0
Ok to proceed? (y)The default answer is yes. The npm exec documentation adds the condition that disables the prompt entirely: "When standard input is not a TTY or a CI environment is detected, --yes is assumed." In those cases npx logs a single warning line and proceeds. CI runners have no TTY, and neither do MCP clients and AI agents that spawn npx with piped standard input.
MCP servers are the largest new population of non-interactive npx calls. The official filesystem server's README tells users to configure their client like this:
{
"mcpServers": {
"filesystem": {
"command": "npx",
"args": ["-y", "@modelcontextprotocol/server-filesystem", "/Users/username/Desktop"]
}
}
}Every launch of that client resolves @modelcontextprotocol/server-filesystem from the registry with no version, passes -y, and runs the result with access to your files. The prompt never appears, and it would not appear without -y either.
The flag worth using is the opposite one. --no, or npm_config_yes=false in the environment, makes npx fail closed when a package is missing:
$ npx --no cowsay hi
npm error npx canceled due to missing packages and no YES option: ["cowsay@1.6.0"]Install-script blocking does not cover the code npx runs
#npm v12 skips dependency install scripts by default, and npx inherits that default. One-off executions have no project package.json to hold an allowScripts list, so npm documents a separate --allow-scripts flag for them. We confirmed the behavior with a local test package that has both a postinstall script and a bin. The script writes a marker file, and cli.js prints its process ID and HOME:
{
"name": "demo-npx-scripts",
"version": "1.0.0",
"bin": { "demo-npx-scripts": "cli.js" },
"scripts": {
"postinstall": "node -e \"require('fs').writeFileSync(process.env.MARK,'postinstall ran')\""
}
}# npm 10.9.9
$ npx --yes file:./demo-npx-scripts-1.0.0.tgz
bin ran as pid 449 with HOME=/root
$ cat "$MARK"
postinstall ran
# npm 12.2.1
$ npx --yes file:./demo-npx-scripts-1.0.0.tgz
bin ran as pid 473 with HOME=/root
$ cat "$MARK"
cat: /tmp/mark: No such file or directoryThe v12 default is a real improvement for npx users. The axios compromise delivered its dropper through a postinstall hook in plain-crypto-js@4.2.1, and v12 would have skipped that hook for a CLI that only pulled the package in transitively.
The default stops at the boundary npx exists to cross. The bin still ran, as the same user, with the same HOME, the same environment variables, and the same network access. A malicious version only needs to put its payload in the code path the command executes, or in any module that code imports. For npx, the install and the execution happen in a single command, so install-time protection cannot be the last line of defense.
Why npx runs outside your lockfile
#A lockfile pins a project's existing dependency tree, and npx resolves a new tree on every uncached run. The axios incident made the difference visible. Starting late on March 30, 2026 (UTC), attackers published a malicious plain-crypto-js@4.2.1, then axios@1.14.1 and axios@0.30.4, which depended on it. Socket's scanner flagged plain-crypto-js@4.2.1 six minutes after it was published, according to our report.
Projects that installed from a committed lockfile with npm ci were generally not affected. Tools run through npx were exposed. In our analysis of the hidden blast radius of the axios compromise, we observed a CI pipeline that ran a CLI through npx, pulled the malicious version through a transitive axios range, and produced command-and-control traffic during a build step. The project's lockfile never mentioned axios.
axios: "^1.13.5".- wait-on
- axios 1.14.0
- wait-on
- axios 1.14.1
- plain-crypto-js 4.2.1
- wait-on
- axios 1.14.0
package-lock.json: identical in all three runs. It never contained wait-on or axios.The figure traces one CI command, npx -y wait-on http://localhost:3000, at three moments. wait-on declared axios: "^1.13.5" at the time of the attack, according to our blast-radius analysis. Before 23:59 UTC on March 30, that range resolves to axios@1.14.0, a clean tree. A run while axios@1.14.1 was the latest release would resolve the same range to it and pull in plain-crypto-js@4.2.1, whose postinstall dropper deploys a remote access trojan. After npm removed the malicious versions, the range resolved to axios@1.14.0 again, so a rerun leaves nothing to find. The project's package-lock.json is identical in all three runs and never contains wait-on or axios.
Pinning the top-level package does not close the gap. npx foo@1.2.3 fixes foo, but every dependency below it is still resolved from its declared semver range at run time. Package authors cannot ship a lock for you either. npm never publishes package-lock.json, and as of npm v12, the npm-shrinkwrap.json inside a dependency's tarball is ignored. The npm 12 lockfile documentation points publishers who need a locked tree to bundleDependencies instead.
Incident response inherits the same blind spot. After a malicious version is removed, re-running the command resolves a clean tree, and the lockfile shows nothing. The ~/.npm/_npx/*/package-lock.json files on developer machines and CI caches are often the only record of what npx actually installed, and ephemeral runners discard them.
npx vs pnpm dlx, yarn dlx, and bunx
#Every major JavaScript package manager now has an npx equivalent, and their defaults differ in ways that matter. We tested the current release of each on October 29, 2026, around 17:50 PT. For the release-age row, we ran each tool against vercel with no version. Its newest release, 65.8.0, had been published about five hours earlier, at 19:45 UTC on October 29.
| npx (npm 12.2.1) | pnpm dlx (pnpm 12.16.1) | yarn dlx (Yarn 4.18.1) | bunx (Bun 1.4.4) | |
|---|---|---|---|---|
| Prompts before installing | Yes, at a TTY only. Auto-yes in CI and non-TTY | No | No | No |
| Dependency install scripts | Skipped unless --allow-scripts | Skipped unless --allow-build | Skipped. Yarn reports that build scripts are disabled | Opt-in with --trust, per bunx --help |
| Release-age gate by default | None. min-release-age defaults to null | 1440 minutes | 1440 minutes (npmMinimalAgeGate) | None. --minimum-release-age is opt-in |
| Version of vercel it ran | 65.8.0, five hours old | 65.5.0 | 65.5.0 | 65.8.0, five hours old |
| Reuse of cached installs | Unversioned specs recheck the registry every run | Cache expires after 1440 minutes (dlxCacheMaxAge) | Temporary environment per run | Unversioned specs recheck once a day |
pnpm and Yarn skipped vercel 65.7.0 as well, because it was about 23 hours and 50 minutes old when we ran the test. pnpm's 24-hour default arrived in pnpm 11.
Our verdict: for running a package you have not pinned, prefer pnpm dlx or yarn dlx over npx. A one-day dependency cooldown keeps short-lived malicious releases, like the axios versions, out of ad hoc execution, and it costs nothing for tools that rarely need a same-day release. Cooldowns are not a guarantee. They do nothing against a malicious version that stays up longer than the window, a name squat, or a bin-name mismatch, and a project or user setting can turn them off. Use npx when you need npm's exact resolution behavior, and then pin the tool locally and add --no, as described below. None of the four runners addresses the deeper problem: they all execute the package's code as you.
Running npx safely in CI and MCP configs
#The safe pattern is to stop letting npx resolve anything. Install every tool you run through npx at an exact version, commit the lockfile, and tell npx to fail if a tool is missing. We published this approach after the axios compromise. In CI, the tool belongs in the repository's own package.json:
npm install --save-dev --save-exact wait-on@9.5.1 # once, locally; commit package-lock.json
npm ci # in the pipeline, never npm install
npx --no --offline wait-on http://localhost:3000--no refuses to install anything that is not already local. --offline forbids network access, so a missing cache entry is an error instead of a fetch. The exact pin plus the committed lockfile give the tool the same reproducibility as any other dependency.
MCP servers need their own pinned directory, because the client launches them from a working directory you do not control:
mkdir -p ~/mcp && cd ~/mcp
npm init --yes
npm install --save-exact @modelcontextprotocol/server-filesystem@2.1.0Once the server is installed, the client config does not need npx at all. Point command at the executable that npm linked into node_modules/.bin:
{
"mcpServers": {
"filesystem": {
"command": "/Users/username/mcp/node_modules/.bin/mcp-server-filesystem",
"args": ["/Users/username/Desktop"]
}
}
}A direct path performs no resolution and no registry lookup, so there is nothing left to squat or race. We confirmed that this config starts the server and answers an MCP initialize request.
The npx variants of the same setup are fragile, which we found while testing them on npm 12.2.1. npx --prefix ~/mcp --no --offline mcp-server-filesystem works, because the command is the bin name and step 2 finds it. Passing the package name instead, @modelcontextprotocol/server-filesystem, fails with sh: 1: mcp-server-filesystem: not found. So does a versioned spec such as cowsay@1.6.0 under --prefix. The workspace form, --include-workspace-root --workspace ~/mcp, failed with ENOENT when we ran it from a directory without a package.json. All of these fail closed, not open, but they break the server. The cost of any pinned setup is maintenance: you now own updates for every tool you used to get for free from latest.
A pinned tree still has to be safe when you create it and when you update it. That is where an install-time control fits. Socket Firewall runs as sfw npm install ..., starts an ephemeral HTTP proxy for the package manager, and blocks requests for packages Socket has already identified as malicious, including transitive ones, before they are downloaded. It has a documented blind spot that matters for npx: it inspects network requests, so artifacts already in the local cache pass through unseen. Clear the npm cache before you start using it, and run it in front of the installs that build your pinned tool directory, which is the package firewall model applied to the one place npx still talks to the registry.
The open problem is that the ecosystem's documentation still teaches npx -y <name> as the default way to run tools, and AI agents now copy that pattern into configs and shell sessions at machine speed. Every one of those lines is an unpinned, unprompted install-and-execute against the live registry. npm has made npm install safer by default. Until npx gets an equivalent, such as a release-age gate by default or a refusal to execute names that do not match a published bin, the safest npx call is the one you have replaced with a path.
