# 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

Source: https://socket.dev/glossary/npx-npm-package-runner

Last updated: 2026-10-30

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](https://socket.dev/blog/npm-12), 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](https://socket.dev/glossary/npm-node-package-manager), 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](https://blog.npmjs.org/post/162869356040/introducing-npx-an-npm-package-runner). The [npm CLI documentation](https://docs.npmjs.com/cli/v12/commands/npx) 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:

```bash
$ 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](https://github.com/npm/cli/blob/latest/workspaces/libnpmexec/lib/index.js) shows. You can reproduce it:

```bash
$ printf 'cowsay@1.6.0' | sha512sum | cut -c1-16
e3e1d528ee105f80
$ printf 'cowsay' | sha512sum | cut -c1-16
8f497369b2d6166e
```

So `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](https://github.com/npm/cli/blob/latest/workspaces/libnpmexec/lib/index.js) that ships with npm 12.2.1:

> npx resolution order in npm 12.2.1, read from libnpmexec.
>
> How npx resolves a command name, in four steps

1. **The current project's own `bin`.** If the `package.json` in the current directory, or a selected workspace, declares a `bin` with that name, npx runs it.
2. **`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.
3. **The global bin directory.** npx checks the directory where `npm install -g` links executables.
4. **The registry.** If nothing matched, npx treats the command name as a package name. It fetches the package manifest from `registry.npmjs.org` with `preferOnline: true` and 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 the `bin` the 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:

```bash
$ 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:

```bash
$ 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 packages
```

The `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](https://socket.dev/glossary/typosquatting) without the typo.

The attack is practical. Researchers at Depi Security [describe finding](https://depi.security/blog/20260521-npx-used-confusion-and-its-super-effective/) 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](https://socket.dev/blog/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](https://socket.dev/glossary/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](https://docs.npmjs.com/cli/v12/commands/npx) 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:

```text
Need to install the following packages:
cowsay@1.5.0
Ok to proceed? (y)
```

The default answer is yes. The [npm exec documentation](https://docs.npmjs.com/cli/v12/commands/npm-exec) 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](https://socket.dev/glossary/mcp-security) are the largest new population of non-interactive npx calls. The official filesystem server's README tells users to configure their client like this:

```json
{
  "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:

```bash
$ 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](https://socket.dev/glossary/postinstall-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`:

```json
{
  "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')\""
  }
}
```

```bash
# 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 directory
```

The v12 default is a real improvement for npx users. The [axios compromise](https://socket.dev/blog/axios-npm-package-compromised) 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](https://socket.dev/glossary/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](https://socket.dev/blog/axios-npm-package-compromised).

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](https://socket.dev/blog/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.

> Illustrative: one command resolving at three moments of the axios compromise. Versions and ranges from Socket's incident reports.
>
> The same npx command resolving three different trees before, during, and after the axios compromise

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](https://socket.dev/glossary/npm-shrinkwrap-json) inside a dependency's tarball is ignored. The [npm 12 lockfile documentation](https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json) 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](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults).

Our verdict: for running a package you have not pinned, prefer `pnpm dlx` or `yarn dlx` over `npx`. A one-day [dependency cooldown](https://socket.dev/glossary/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`:

```bash
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:

```bash
mkdir -p ~/mcp && cd ~/mcp
npm init --yes
npm install --save-exact @modelcontextprotocol/server-filesystem@2.1.0
```

Once 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`:

```json
{
  "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](https://socket.dev/blog/introducing-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](https://socket.dev/glossary/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.
