Back to glossary

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

pnpm (performant npm) is a JavaScript 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, pnpm blocks dependency install scripts by default, and since version 11 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 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 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 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:

Bash
$ 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.

npm 12

flat, hoisted

project node_modules/

debug/ copy

ms/ copy

require('ms') from the app: resolves

pnpm 12

isolated, symlinked, hard-linked

1 · project node_modules/

debug symlink

↓ points into

2 · virtual store node_modules/.pnpm/

debug@4.4.1/node_modules/

debug/ files

ms symlink → ms@2.1.3/…

ms@2.1.3/node_modules/ms/ files

node_modules/ms hidden hoist

↓ every file is a hard link to

3 · store, one per machine

~/.local/share/pnpm/store/v11/files/51/b45089…

index.db SQLite index

require('ms') from the app: MODULE_NOT_FOUND
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.

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. 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 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.

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, 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.

Defaultnpm 12.2.1pnpm 12.15.0
node_modules layoutFlat: every package hoisted to the rootRoot holds symlinks to direct dependencies only
Copies on diskOne per projectOne per machine, hard-linked into each project
Import of an undeclared packageWorks if the package was hoistedFails at the project root
Dependency install scriptsOff: allow-scripts is emptyOff since pnpm 10.0.0
Implicit node-gyp build from binding.gypBlockedBlocked
Unapproved script during installSkipped with a warning, install succeedsInstall fails with ERR_PNPM_IGNORED_BUILDS, exit 1
Approval the CLI writesExact version: allow-scripts-pin = truePackage name only: esbuild: true
Release-age delayOff: min-release-age = null1440 minutes, soft unless set explicitly
Git and tarball dependenciesRefused: allow-git and allow-remote = noneAllowed if direct, refused if transitive
Install from lockfile onlynpm cipnpm 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. 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 waves used a technique called Phantom Gyp: 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:

Bash
$ 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 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, 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 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: 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 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.

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.

Bash
# 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 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, 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, our analysis of its blast radius made the same point about npx: "Lockfiles make existing installs deterministic. They do not make new installs safe."

1. Is this version already in pnpm-lock.yaml?

YesInstalled as locked. Resolution is skipped, so the age is never checked, even with a strict setting.
Nopnpm resolves it. Go to 2.

↓ no

2. Does a version older than minimumReleaseAge satisfy the range?

YesNewest mature version wins. A range over TypeScript nightlies resolved the Oct 24 build, not the Oct 25 one.
NoTypical for an exact pin to a fresh version. Go to 3.

↓ no

3. Was minimumReleaseAge set explicitly?

Yes: strictInstall fails with ERR_PNPM_NO_MATURE_MATCHING_VERSION.
No: default 1440Fresh version installs, exit 0, and pnpm appends it to minimumReleaseAgeExclude.
Figure 2. What we observed with pnpm 12.15.0 on October 25, 2026.

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.

VersionReleasedSecurity change
10.0.0January 7, 2025Dependency lifecycle scripts blocked by default
10.16.0September 12, 2025minimumReleaseAge added, default 0
10.21.0November 9, 2025trustPolicy added, default off
10.26.0December 15, 2025allowBuilds and blockExoticSubdeps added; git dependency script bypass (CVE-2025-69264) fixed
11.0.0April 28, 2026minimumReleaseAge defaults to 1440; blockExoticSubdeps on; unapproved builds fail the install
11.10.0July 4, 2026_auth registry credentials ignored in project files
12.0.0August 26, 2026Rust 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, 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.

Closing that gap means checking package contents before they reach the store. Socket Firewall 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. 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.