# pnpm

> pnpm is an npm-compatible JavaScript package manager that hard-links packages from a global store and blocks dependency install scripts by default.

Also known as: performant npm, pnpm-lock.yaml, pnpm-workspace.yaml, pnpm dlx, pnpx, pnpm approve-builds, allowBuilds, minimumReleaseAge

Source: https://socket.dev/glossary/pnpm

Last updated: 2026-10-25

pnpm (performant npm) is a JavaScript [package manager](https://socket.dev/glossary/package-manager) that installs packages from the npm registry into a global content-addressable store and symlinks them into `node_modules`, so a project can only import the dependencies it declares. Since [version 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default), pnpm blocks dependency [install scripts](https://socket.dev/glossary/postinstall-scripts) by default, and since [version 11](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults) it delays versions published less than 24 hours ago.

We think pnpm ships the strongest supply chain defaults of any mainstream JavaScript package manager. We also found that two of them are softer than they look. As of October 2026, the current release is pnpm 12.15.0, published October 21, part of the Rust rewrite that [shipped as pnpm 12](https://socket.dev/blog/pnpm-12) in August. In our tests, its 24-hour release-age delay applied only when pnpm picked a version from a range. An exact version pin, or a version already recorded in `pnpm-lock.yaml`, installed a TypeScript build published about four and a half hours earlier, with exit code 0. In the pin case, pnpm also wrote the fresh version into `minimumReleaseAgeExclude` in `pnpm-workspace.yaml`, turning a one-time bypass into committed configuration. Script approval has a similar gap: `pnpm approve-builds` approves a package by name, so every future version of that package may run code at install. The pin and approval gaps close with a few lines of configuration, and the lockfile gap needs review. We cover all three below.

## What is pnpm?

pnpm is an npm-compatible package manager: it reads the same `package.json`, installs from the same npm registry, and produces a `node_modules` directory that Node.js resolves with its normal algorithm. Rico Sta. Cruz started pnpm in 2016, version 1.0 shipped on June 28, 2017, and Zoltan Kochan leads it today. Where pnpm differs from [npm](https://socket.dev/glossary/npm-node-package-manager) is how packages land on disk. Every pnpm install builds three layers:

- The store. Each file of each package version is saved once per machine, named by the SHA-512 hash of its contents, under a path such as `~/.local/share/pnpm/store/v11/files/`. Since pnpm 11 the store's index is a single SQLite database. [pnpm's docs](https://pnpm.io/motivation) state the saving plainly: npm keeps 100 copies of a dependency that 100 projects use.
- The virtual store. Each package version gets a directory at `node_modules/.pnpm/<name>@<version>/node_modules/<name>`, and its files are hard links into the store. The package's own dependencies are symlinked beside it in that inner `node_modules`.
- The project root. The top-level `node_modules` holds symlinks to the project's direct dependencies and nothing else.

We installed `debug@4.4.1`, which depends on `ms@2.1.3`, into an empty project with pnpm 12.15.0. The output is trimmed to the relevant lines:

```sh
$ pnpm add debug@4.4.1
$ ls -l node_modules
debug -> .pnpm/debug@4.4.1/node_modules/debug
$ ls -l node_modules/.pnpm/debug@4.4.1/node_modules
drwxr-xr-x debug
lrwxrwxrwx ms -> ../../ms@2.1.3/node_modules/ms
$ stat -c %h node_modules/.pnpm/ms@2.1.3/node_modules/ms/index.js
3
$ sha512sum node_modules/.pnpm/ms@2.1.3/node_modules/ms/index.js | cut -c1-12
51b45089a53a
$ ls ~/.local/share/pnpm/store/v11/files/51/ | grep ^b45089a53a
b45089a53a23c12e28eb889396e2fa71b95085baa5ac34d71ffb625131bf2fec3ae98efeae537656e20ea257f44e089bcebc9ad54cf672cde852102e43e153
$ node -e "require('ms')"
Error: Cannot find module 'ms'
  code: 'MODULE_NOT_FOUND'
```

The link count of 3 is the store entry plus two projects on our test machine that share one copy of the file. The store file's name is the first two hex characters of its SHA-512 hash as a directory, then the rest of the hash, which is how pnpm knows that two package versions contain the same bytes.

> Figure 1. The same install, debug@4.4.1 and its one dependency ms@2.1.3, under npm 12 and pnpm 12.15.0.
>
> node\_modules layout under npm and pnpm for debug@4.4.1, which depends on ms@2.1.3

Figure 1 as text. Under npm 12, both `debug` and `ms` are copied into the project's top-level `node_modules`, so application code can `require('ms')` even though `package.json` never lists it. Under pnpm 12.15.0, the top level holds one symlink, `debug`, which points to `node_modules/.pnpm/debug@4.4.1/node_modules/debug`. That inner `node_modules` also holds a symlink named `ms` that points to `node_modules/.pnpm/ms@2.1.3/node_modules/ms`. Both packages' files are hard links into the per-machine store at `~/.local/share/pnpm/store/v11`, which pnpm indexes in a SQLite file named `index.db`. A hidden link to `ms` also sits in `node_modules/.pnpm/node_modules`. From the application, `require('ms')` fails with `MODULE_NOT_FOUND`.

That failure is the point of the layout. npm and Yarn Classic hoist every package to the root, so code can import a transitive dependency it never declared, which is called a [phantom dependency](https://socket.dev/glossary/phantom-dependencies). Under pnpm, `debug` can load `ms` because `ms` sits in `debug`'s own `node_modules`. The project cannot, because nothing put `ms` at the root.

pnpm's isolation is semistrict by default. The default `hoistPattern` of `['*']` also links every package into the hidden `node_modules/.pnpm/node_modules`, so a dependency that forgot to declare one of its own imports still finds it through Node's parent-directory lookup. `publicHoistPattern`, empty by default, controls what reaches the project root. Setting `nodeLinker: hoisted` builds an npm-style flat tree and gives up the isolation. [pnpm's node-modules settings](https://pnpm.io/settings/node-modules) document all three.

pnpm 12, released August 26, 2026, replaced the Node.js implementation with a Rust binary and kept pnpm 11's commands, settings, and lockfile format. On Vercel's 21-project Turborepo workspace, pnpm 12 cut median install times by 64.4% to 90.5% compared with pnpm 10.28, according to [our coverage of the release](https://socket.dev/blog/pnpm-12).

## What is the difference between pnpm and npm?

The difference between pnpm and npm is how each lays out `node_modules` and what each lets a dependency do by default. Both install the same packages from the same registry. The security gap between them narrowed recently: [npm v12](https://socket.dev/blog/npm-12), released July 8, 2026, turned off dependency install scripts by default, 18 months after pnpm 10.0.0 did. The table shows the defaults we observed with npm 12.2.1 and pnpm 12.15.0 on October 25, 2026.

| Default | npm 12.2.1 | pnpm 12.15.0 |
| --- | --- | --- |
| node\_modules layout | Flat: every package hoisted to the root | Root holds symlinks to direct dependencies only |
| Copies on disk | One per project | One per machine, hard-linked into each project |
| Import of an undeclared package | Works if the package was hoisted | Fails at the project root |
| Dependency install scripts | Off: allow-scripts is empty | Off since pnpm 10.0.0 |
| Implicit node-gyp build from binding.gyp | Blocked | Blocked |
| Unapproved script during install | Skipped with a warning, install succeeds | Install fails with ERR\_PNPM\_IGNORED\_BUILDS, exit 1 |
| Approval the CLI writes | Exact version: allow-scripts-pin = true | Package name only: esbuild: true |
| Release-age delay | Off: min-release-age = null | 1440 minutes, soft unless set explicitly |
| Git and tarball dependencies | Refused: allow-git and allow-remote = none | Allowed if direct, refused if transitive |
| Install from lockfile only | npm ci | pnpm install, frozen by default in CI |

Our verdict: pnpm is the better default for teams that install third-party code. It fails closed on unreviewed build scripts, delays fresh releases, and stops phantom imports that hide real dependencies from review. npm 12 has the stronger approval model, because a reviewed script stays pinned to the version you reviewed. If you run pnpm, close that gap yourself with version-pinned `allowBuilds` entries, shown below. Stay on npm when a tool in your pipeline assumes a flat `node_modules` and you cannot fix it. pnpm's `nodeLinker: hoisted` exists for that case, but it gives back the isolation.

## How does pnpm block install scripts?

pnpm blocks install scripts by refusing to run a dependency's `preinstall`, `install`, or `postinstall` script, or its implicit node-gyp build, unless `allowBuilds` approves that package. Install scripts are the classic execution point for malicious packages, because they run during install, before any application code imports the package. pnpm 10.0.0, released January 7, 2025, made blocking the default in response to the [Rspack supply chain attack](https://socket.dev/blog/rspack-supply-chain-attack). The compromised `@rspack/core` and `@rspack/cli` 1.1.7 releases ran a cryptominer from a `postinstall` script.

Attackers have since moved scripts out of `package.json`. The [Miasma](https://socket.dev/supply-chain-attacks/miasma-mini-shai-hulud-supply-chain-attack) waves used a technique called [Phantom Gyp](https://socket.dev/blog/miasma-mini-shai-hulud-hits-leoplatform-npm-packages-go-ecosystem): a `binding.gyp` file whose command expansion runs JavaScript when the package manager builds it as a native addon. We packed a local package whose only content is such a file, set to write a marker file, and installed it next to esbuild:

```sh
$ cat package.json
{"dependencies":{"gyp-evil":"file:../evil/gyp-evil-1.0.0.tgz","esbuild":"0.25.5"}}
$ pnpm install
Error: ERR_PNPM_IGNORED_BUILDS

  × installing dependencies
  ╰─▶ Ignored build scripts: esbuild@0.25.5, gyp-evil@file:../evil/gyp-evil-
      1.0.0.tgz
  help: Run "pnpm approve-builds" to pick which dependencies should be allowed
        to run scripts.
$ echo $?
1
```

The payload did not run, and the marker file never appeared. pnpm also stopped the install. pnpm 10.28.0 printed the same list as a warning and exited 0, while pnpm 11.0.0 and later exit 1. We confirmed both by installing each version. Failing is the right default for CI, because a skipped build otherwise surfaces later as a runtime error when a native module loads.

Approvals live in `pnpm-workspace.yaml`. This is what `pnpm approve-builds esbuild` wrote:

```yaml
allowBuilds:
  esbuild: true
```

That entry approves every future esbuild release, including one an attacker publishes with a stolen maintainer token. npm 12 pins approvals to the reviewed version by default: [npm's documentation](https://docs.npmjs.com/cli/v12/commands/npm-approve-scripts) says `approve-scripts` writes pinned `pkg@1.2.3` entries. pnpm's `allowBuilds` accepts the same precision if you write the entry by hand:

```yaml
allowBuilds:
  esbuild@0.25.5: true
  gyp-evil: false
```

With that file, esbuild 0.25.5 built and installed. When we bumped the project to esbuild 0.25.6, pnpm failed again with `Ignored build scripts: esbuild@0.25.6` until someone approved the new version. That is a review on every upgrade of a package with scripts, and reviewers who see a prompt on every release learn to approve without reading. We still think it is the right trade for code that runs on developer machines and CI runners.

Git dependencies were the gap in pnpm's script blocking until December 2025. [GHSA-379q-355j-w6rj](https://github.com/pnpm/pnpm/security/advisories/GHSA-379q-355j-w6rj), published as CVE-2025-69264, showed that pnpm 10 releases before 10.26.0 still ran a git-hosted dependency's `prepare`, `prepublish`, and `prepack` scripts while fetching it, with no approval. GitHub rates it High, with a CVSS 3.1 score of 8.8. pnpm 10.26.0 fixed it and added `blockExoticSubdeps`, which pnpm 11 turned on by default. That setting refuses [Git and URL dependencies](https://socket.dev/glossary/git-dependencies) anywhere below your direct dependencies. Direct ones are still allowed. As of pnpm 12, a bare package name in `allowBuilds` never approves a git or tarball dependency; the entry must name the exact resolved commit or the repository URL.

## Does pnpm's minimumReleaseAge protect you by default?

pnpm's `minimumReleaseAge` protects you by default only when pnpm is choosing a version from a range. The setting is pnpm's [dependency cooldown](https://socket.dev/glossary/dependency-cooldown): the number of minutes a version must exist before pnpm will install it. pnpm added it in 10.16.0 in September 2025 and made 1440 minutes, one day, the default in pnpm 11.0.0 on April 28, 2026. The bet is that most malicious releases are caught quickly. [pnpm's settings reference](https://pnpm.io/settings/dependency-resolution) says most are discovered and removed within an hour.

pnpm's maintainer argued for a default rather than an option when the community debated the setting after 10.16 shipped. "If the default doesn't change, some of the users will still download malware before it gets removed from the registry," the maintainer wrote, as [we reported for pnpm 10.16](https://socket.dev/blog/pnpm-10-16-adds-new-setting-for-delayed-dependency-updates).

The default has a softer edge than its headline. pnpm 11 also added `minimumReleaseAgeStrict`, which defaults to true only if you set `minimumReleaseAge` yourself. When strict mode is off and no mature version satisfies a dependency, pnpm installs the immature one. We tested the cases with pnpm 12.15.0 against TypeScript's nightly builds, which publish once a day. The newest, `7.1.0-dev.20261025.1`, was published at 08:16 UTC on October 25, about four and a half hours before our runs.

```sh
# 1. Default settings, range: the newest mature build wins
#    "typescript": ">=7.1.0-dev.20261020 <7.1.0"
$ pnpm install
+ typescript 7.1.0-dev.20261024.1

# 2. Default settings, exact pin to the fresh build
#    "typescript": "7.1.0-dev.20261025.1"
$ pnpm install
Added 8 entries to minimumReleaseAgeExclude in pnpm-workspace.yaml (set minimumReleaseAgeStrict to true to gate these updates with a prompt):
  @typescript/typescript-darwin-arm64@7.1.0-dev.20261025.1
  ...
  typescript@7.1.0-dev.20261025.1
+ typescript 7.1.0-dev.20261025.1
$ echo $?
0

# 3. Same pin, with minimumReleaseAge: 1440 set explicitly
$ pnpm install
Error: ERR_PNPM_NO_MATURE_MATCHING_VERSION

  × installing dependencies
  ╰─▶ 8 versions do not meet the minimumReleaseAge constraint:
        ...
        typescript@7.1.0-dev.20261025.1 was published at 2026-10-
      25T08:16:27.291Z, within the minimumReleaseAge cutoff (2026-10-
      24T12:45:51.348Z)
$ echo $?
1
```

Case 2 is the one to worry about. The 8 entries are `typescript` and its seven platform binary packages. pnpm did not just let them through once. It wrote them into the project's configuration, where they will be committed with the next change:

```yaml
minimumReleaseAgeExclude:
  - '@typescript/typescript-darwin-arm64@7.1.0-dev.20261025.1'
  - '@typescript/typescript-darwin-x64@7.1.0-dev.20261025.1'
  - '@typescript/typescript-linux-arm64@7.1.0-dev.20261025.1'
  - '@typescript/typescript-linux-arm@7.1.0-dev.20261025.1'
  - '@typescript/typescript-linux-x64@7.1.0-dev.20261025.1'
  - '@typescript/typescript-win32-arm64@7.1.0-dev.20261025.1'
  - '@typescript/typescript-win32-x64@7.1.0-dev.20261025.1'
  - typescript@7.1.0-dev.20261025.1
```

Exact pins to brand-new versions are not exotic. They are what a developer types after reading a release announcement, and what an update bot writes for a project that pins exact versions, unless the bot runs its own cooldown. Each of them runs with whatever settings the repository has, which by default means the soft gate.

The [lockfile](https://socket.dev/glossary/lockfile) is the second bypass. We took the lockfile from case 2, deleted the exclusion list, set `minimumReleaseAge: 1440` explicitly, and ran `pnpm install --frozen-lockfile`. pnpm printed `Lockfile is up to date, resolution step is skipped` and installed the build published four and a half hours earlier, with exit code 0. The age gate runs during resolution, and a frozen install does not resolve. A pull request that adds a fresh version to `pnpm-lock.yaml` therefore passes a CI job that installs from the lockfile, whatever the CI config says about release age.

`pnpm dlx`, pnpm's equivalent of [npx](https://socket.dev/glossary/npx-npm-package-runner), follows the same rules. Inside a project with an explicit setting, `pnpm dlx typescript@7.1.0-dev.20261025.1 --version` failed with the same error. From a directory with no pnpm config, it downloaded and ran the fresh build. Nothing `pnpm dlx` fetches is recorded in a project lockfile. After [the axios compromise](https://socket.dev/blog/axios-npm-package-compromised), [our analysis of its blast radius](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) made the same point about npx: "Lockfiles make existing installs deterministic. They do not make new installs safe."

> Figure 2. What we observed with pnpm 12.15.0 on October 25, 2026.
>
> How pnpm 12.15.0 applies minimumReleaseAge to a dependency

Figure 2 as text. Step 1: if a version is already in `pnpm-lock.yaml`, pnpm installs it as locked and skips resolution, so release age is not checked, even with a strict setting. Step 2: otherwise pnpm resolves the dependency, and if any version older than `minimumReleaseAge` satisfies the range, pnpm picks the newest such version, as when our TypeScript range resolved the October 24 nightly instead of the October 25 one. Step 3: if no mature version satisfies the range, which is typical for an exact pin to a fresh version, the outcome depends on whether `minimumReleaseAge` was set explicitly. If it was, `minimumReleaseAgeStrict` defaults to true and the install fails with `ERR_PNPM_NO_MATURE_MATCHING_VERSION`; pnpm's error message says an interactive install can instead approve the picks. If it was not, the fresh version installs with exit code 0 and pnpm appends it to `minimumReleaseAgeExclude` in `pnpm-workspace.yaml`.

The fix for case 2 is to write down the default you think you already have. These lines make pnpm fail instead of silently excluding:

```yaml
minimumReleaseAge: 1440
minimumReleaseAgeStrict: true
trustPolicy: no-downgrade
```

`trustPolicy: no-downgrade`, off by default, fails an install when a package version carries weaker publishing evidence than its earlier releases, such as a token-published release of a package that previously used trusted publishing. For the lockfile bypass, the defense is review: treat any pull request that changes `pnpm-lock.yaml` or `minimumReleaseAgeExclude` as a dependency change, because it is one.

A cooldown buys detection time and nothing else. It delays legitimate security fixes, which is why `minimumReleaseAgeExclude` exists, and it does nothing against a malicious release that stays undetected for longer than the window. We called release-age settings a stopgap when pnpm 10.16 introduced them, and the 2026 campaigns have not changed that view.

## pnpm security defaults by version

pnpm's supply chain defaults arrived over about 20 months, most of them after specific attacks. Dates are npm registry publish dates.

| Version | Released | Security change |
| --- | --- | --- |
| 10.0.0 | January 7, 2025 | Dependency lifecycle scripts blocked by default |
| 10.16.0 | September 12, 2025 | minimumReleaseAge added, default 0 |
| 10.21.0 | November 9, 2025 | trustPolicy added, default off |
| 10.26.0 | December 15, 2025 | allowBuilds and blockExoticSubdeps added; git dependency script bypass (CVE-2025-69264) fixed |
| 11.0.0 | April 28, 2026 | minimumReleaseAge defaults to 1440; blockExoticSubdeps on; unapproved builds fail the install |
| 11.10.0 | July 4, 2026 | \_auth registry credentials ignored in project files |
| 12.0.0 | August 26, 2026 | Rust rewrite; unknown settings in pnpm-workspace.yaml reported instead of ignored |

## What pnpm's defaults do not stop

pnpm's defaults do not stop a compromised package that runs its payload when your code imports it, and they do not stop a malicious release that is old enough. Every control above keys on how a package is delivered: its install hooks, its age, its source. None reads what the package contains. [Mini Shai-Hulud](https://socket.dev/supply-chain-attacks/mini-shai-hulud), first seen April 29, 2026, compromised 1,069 package artifacts across 507 npm and PyPI packages, according to our campaign tracker as of October 2026. Its loader runs from a `preinstall` hook or at import time, and pnpm's script blocking covers only the first.

Publisher trust has the same limit. `trustPolicy` catches a downgrade in how a release was published. It cannot catch a compromised maintainer who publishes through the project's normal pipeline, because that release carries the same evidence as the last good one. The Miasma waves came from compromised maintainer accounts publishing through legitimate pipelines, some with valid provenance, as [we noted when npm v12 shipped](https://socket.dev/blog/npm-12).

Closing that gap means checking package contents before they reach the store. [Socket Firewall](https://docs.socket.dev/docs/socket-firewall-free) lists pnpm among its supported package managers. Prefixing the command, as in `sfw pnpm install`, runs it through an ephemeral HTTP proxy that checks each requested artifact against Socket's analysis and blocks known malware before download. pnpm's architecture creates one caveat. The firewall sees only network fetches, and pnpm downloads a package version once per machine, so a version already in the content-addressable store is not checked again.

## pnpm's policy lives in the repository it polices

pnpm keeps its security policy in `pnpm-workspace.yaml`, a file that a pull request, an update bot, or pnpm itself can edit. `allowBuilds`, `minimumReleaseAge`, `minimumReleaseAgeExclude`, and `trustPolicy` all live in the repository, so anyone who can change the repository can loosen them. Case 2 above shows pnpm loosening one on its own. pnpm already applies the opposite rule to credentials. Since pnpm 11.10, it reads the `_auth` registry setting only from the environment or the global config, never from project files, so a tampered repository cannot redirect a token, as [we covered at release](https://socket.dev/blog/pnpm-11-1-hardens-registry-authentication). pnpm 12 also started reporting unknown settings in `pnpm-workspace.yaml`, so a misspelled `minimumReleaseAge` no longer fails silently.

pnpm does read the same settings from a global config file, `~/.config/pnpm/config.yaml` on Linux, but project files win. We set `minimumReleaseAge: 1440` and `minimumReleaseAgeStrict: true` globally, and the exact pin from case 2 failed as expected. Adding `minimumReleaseAge: 0` to the project's `pnpm-workspace.yaml` made the same install succeed with exit code 0.

Supply chain policy deserves the same treatment as credentials. We would like pnpm to treat global and CI-level settings as a floor that project files can tighten but never relax, and to stop writing exclusions without asking. Until then, the safest pnpm setup is its default one written down explicitly, with every change to `pnpm-workspace.yaml` reviewed like a change to code.
