Node.js Package Management & Supply-Chain
Node.js Package Management & Supply-Chain
Overview
This reference is the consumer side of the npm ecosystem: how you install, resolve, lock, and secure the dependencies a Node.js project pulls in. It covers the four mainstream package managers (npm, pnpm, Yarn Berry, bun), the lockfile + reproducible-install contract, workspaces/monorepos, semver resolution and override mechanics, npm scripts/lifecycle, and the supply-chain hardening surface (audit, provenance, install-script defenses, PM pinning).
It deliberately stops at three boundaries owned by sibling references:
nodejs-module-resolution— owns how Node finds a module at runtime (require/CJS,ESM_RESOLVE, theexports/importsfields, conditional exports). This file gets the bytes onto disk; that file resolves a specifier against them.devops-containers-cicd(library packaging) — owns publishing a library:npm publish,files/exportsfor distribution, ESM↔CJS dual builds,semantic-release. This file consumes the registry; that one ships to it. (Provenance here is covered only as a consumer verification + the--provenancepublish flag.)nodejs-typescript-and-runtime-features— owns native TS loading, SEA, and the fullnode --runruntime story. This file referencesnode --runonly for its lifecycle-script behavior.
The mental model: manifest (package.json) declares intent → resolver picks concrete
versions → lockfile freezes them → installer materializes node_modules (or a PnP map) →
lifecycle scripts run → audit/provenance/pinning guard the supply chain.
Core concepts
1. The package managers — install models that differ at the layout layer
All four read package.json, but they materialize dependencies very differently:
- npm — the default. Builds a flat, hoisted
node_modules: transitive deps are pulled up to the top level when versions allow. Simple and maximally compatible, but hoisting exposes phantom dependencies — your code canrequirea package you never declared (because a transitive dep hoisted it), and the import silently breaks the day that transitive dep drops it. - pnpm — a content-addressable global store (
~/.pnpm-store/~/.local/share/pnpm): every file of every package version is stored once and hard-linked into projects, so N projects sharing a version cost ~one copy on disk.node_modulesis symlinked and isolated: only packages you actually declared are reachable at the top level (the rest live undernode_modules/.pnpm/), which eliminates phantom deps by construction. Fastest on large/monorepo installs. - Yarn (Berry, v2+) with Plug’n’Play (PnP) — eliminates
node_modulesentirely. Resolution lives in a generated.pnp.cjsloader that maps every package to its location inside zipped caches; Node loads it via a runtime hook. Strict (“semantic erroring” on undeclared deps), fast, low storage, and supports zero-installs (commit the cache +.pnp.cjs). Fallback to a classic layout vianodeLinker: node-modulesin.yarnrc.ymlfor tools that can’t speak PnP (e.g. some React Native setups). - bun install — Bun’s installer is a drop-in for
npm installthat uses a global cache (~/.bun/install/cache/) with hardlinks / copy-on-write, parallel downloads, and platform-tuned syscalls (hardlinkbackend on Linux). Materializes a normalnode_modules; markedly faster than npm on cold and warm installs.
2. Lockfiles — the reproducibility contract
A lockfile pins the entire resolved tree (exact versions + resolved URLs + integrity hashes, typically SHA-512 / SRI) so a second install reproduces the first bit-for-bit. Each PM has its own:
| PM | Lockfile | Notes |
|---|---|---|
| npm | package-lock.json |
JSON; integrity field is the SRI hash verified on install. |
| pnpm | pnpm-lock.yaml |
YAML; encodes the isolated layout + peer resolution. |
| Yarn | yarn.lock |
Berry uses a YAML-ish format; Classic a custom one. |
| bun | bun.lock (text) |
Replaced the binary bun.lockb in Bun 1.2; text diffs cleanly. |
- Always commit the lockfile for apps (libraries usually publish without dictating consumers’ trees). It is the single source of truth for what actually ran in CI/prod.
npm civsnpm install:npm installreconciles — if a range inpackage.jsonno longer matches the lock, it re-resolves and rewrites the lock.npm ciis strict and reproducible: it requires an existing lock that matchespackage.json, wipesnode_modules, installs exactly the locked versions, and errors on any mismatch (it never edits the lock). Usenpm ciin CI/CD and post-clone. The frozen equivalents:yarn install --frozen-lockfile(Classic) /--immutable(Berry),pnpm install --frozen-lockfile,bun install --frozen-lockfile.
3. Workspaces / monorepos
A workspace is a repo of multiple packages sharing one install + one lock, with local packages linked to each other instead of being fetched from the registry.
- Declaration: npm and Yarn use a
"workspaces": [...]array in the rootpackage.json; pnpm uses a dedicatedpnpm-workspace.yaml(packages:globs). - The
workspace:protocol (originated in pnpm; supported by Yarn Berry and npm 7+): a dependency written"pkg-a": "workspace:*"(orworkspace:^) must resolve to the local workspace package, never the registry. On publish, the PM rewritesworkspace:*to the concrete version so external consumers get a normal range. - Hoisting vs isolation: npm/Yarn-classic hoist shared deps to the root (re-exposing phantom-dep risk across the monorepo); pnpm keeps each package isolated by default.
- Running scripts across packages: npm
--workspace=<name>/--workspaces; Yarnyarn workspace <name> <cmd>/yarn workspaces foreach; pnpm--filter <name>and-r(recursive). pnpm/Yarn order execution by the dependency graph (build a package’s local deps first); this topological awareness is a major monorepo win.
4. Dependency resolution & semver
Ranges in package.json are semver (MAJOR.MINOR.PATCH); the resolver picks the
highest published version satisfying every constraint, then dedupes shared transitive
deps.
- Range operators:
^1.2.3allows the leftmost-non-zero to stay fixed →>=1.2.3 <2.0.0(npm/Yarn/pnpm default onadd);~1.2.3allows only patch bumps →>=1.2.3 <1.3.0. Beware^0.x:^0.2.3means>=0.2.3 <0.3.0(minor is treated as breaking while major is 0). Also1.2.x,*,>=,||, hyphen ranges. peerDependencies— “the host must provide this” (e.g. a React plugin peersreact). Auto-install behavior differs per PM — state it precisely:- npm 7+: auto-installs missing peers by default (npm ≤6 only warned).
- pnpm 8+:
auto-install-peersdefaults to true (it wasfalsein v7). - Yarn Berry: does not auto-install peers — it warns and you add them yourself. On a conflicting peer requirement, PMs decline to auto-install and warn.
optionalDependencies— install failure is non-fatal (used for platform-specific native binaries); guard usage with try/catch since they may be absent.- Overriding a transitive version (force a patched nested dep) — the field name
differs per PM: npm
"overrides", Yarn"resolutions", pnpm"pnpm.overrides". The primary lever for fast supply-chain remediation when a deep dep is vulnerable but its parent hasn’t bumped. engines— declares supportednode/PM versions; advisory by default, enforced withengine-strict(npm) /engineStrict. Pair with thepackageManagerfield (concept 6).
5. npm scripts & lifecycle
"scripts" in package.json defines named commands run via npm run <name>. npm
auto-wraps any script with hooks: running <name> executes pre<name> → <name> →
post<name> in sequence (e.g. prebuild/build/postbuild).
- Install lifecycle:
preinstall→install→postinstallrun duringnpm install.postinstallis the most-abused hook — it’s how a malicious dependency gets arbitrary code execution onnpm install(see concept 6). prepareruns on local install (no package args) and beforenpm pack/npm publish; npm guidance is to usepreparefor build steps, notinstall/preinstall— the only legitimate use ofinstall/preinstallis native compilation that must happen on the target architecture.- Security note —
node --runskips pre/post hooks. Node’s built-innode --run <script>executes only the named script and, by intentional design, does NOT runpre/postlifecycle scripts (and ignoresNODE_OPTIONS). That makes it faster and more predictable, but means apredeploy/postdeployyou rely on will silently not run. For the fullnode --runruntime semantics seenodejs-builtin-modules-modern(ornodejs-typescript-and-runtime-features).
6. Supply-chain security
The dependency graph is the largest untrusted attack surface in a Node app. The defenses:
npm auditcross-checks the installed tree against the advisory DB;npm audit fixremediates within ranges,--forcemay bump majors (review the diff).npm audit signaturesverifies registry signatures and provenance attestations — tying audit to concept-6 provenance.- Provenance & trusted publishing (GA 2023, via Sigstore): publishing with
npm publish --provenancefrom a supported CI (GitHub Actions, GitLab) creates a cryptographically signed, publicly logged (Rekor transparency log) link between the published tarball, the source commit, and the build. Trusted publishing uses short-lived CI OIDC tokens instead of long-lived npm tokens, so a leaked token can’t publish. As a consumer, you verify withnpm audit signatures/ the package’s provenance badge. (Authoring/publishing detail lives indevops-containers-cicd.) - Install-scripts attack surface: a compromised package’s
postinstallruns onnpm install.--ignore-scripts(ornpm config set ignore-scripts true) blocks all lifecycle scripts — OWASP calls it the single most effective mitigation; re-enable per-package only for deps that genuinely need native compilation.--omit=dev/--omit=optionaltrims the prod install surface (replaces the old--production/--no-optional). - Dependency confusion (a.k.a. substitution): if a build pulls from a private and the
public registry, an attacker publishes a public package with your internal name at a
higher version, and the resolver grabs the malicious one. Mitigate by scoping
internal packages (
@org/...) and pinning that scope’s registry in.npmrc, and never letting a public range win over an internal name. - Lockfile injection: a malicious PR edits the lockfile to point a name at a different
tarball/URL while leaving
package.jsoninnocent-looking. Mitigate by reviewing lockfile diffs, usingnpm ci/frozen installs (which honor the lock’s integrity hash), and version/cooldown policies (rejecting versions published in the last N days blunts fast-moving compromise windows). - Pinning the package manager — corepack + the
packageManagerfield:"packageManager": "[email protected]+sha224.<hash>"(name@version, optional but recommended hash). Corepack (shipped with Node) reads it and transparently runs that exact PM version, so every contributor and CI uses the same tool — closing a reproducibility/trust gap the lockfile alone can’t (the lock pins deps, not the resolver).npm,pnpm,yarnare the permitted values; explicit pinning beatsCOREPACK_ENABLE_AUTO_PIN.
Package-manager comparison
| Dimension | npm | pnpm | Yarn Berry (PnP) | bun |
|---|---|---|---|---|
| Install model / layout | Flat hoisted node_modules |
Content-addressable store + symlinked, isolated node_modules |
No node_modules — .pnp.cjs resolution map (or nodeLinker: node-modules) |
Flat node_modules from a global cache |
| Store / cache | Per-project copy (global cache _cacache) |
Global store, hard-linked (one copy/version on disk) | Zipped caches; supports zero-installs | Global cache w/ hardlink / copy-on-write |
| Lockfile | package-lock.json |
pnpm-lock.yaml |
yarn.lock |
bun.lock (text) |
| Workspace config | workspaces in package.json |
pnpm-workspace.yaml |
workspaces in package.json |
workspaces in package.json |
| Run across packages | --workspace / --workspaces |
--filter / -r (graph-ordered) |
yarn workspace(s) / foreach |
--filter |
| peerDeps auto-install | On (npm 7+) | On (pnpm 8+, auto-install-peers) |
Off (warns) | On (npm-compatible) |
| Override field | overrides |
pnpm.overrides |
resolutions |
overrides (npm-compatible) |
| Phantom-dep strictness | Loose (hoist exposes undeclared) | Strict by construction | Strict (“semantic erroring”) | Loose (flat layout) |
| Frozen/CI install | npm ci |
--frozen-lockfile |
--immutable |
--frozen-lockfile |
Practical patterns
- CI/CD always uses the frozen install —
npm ci/pnpm i --frozen-lockfile/yarn install --immutable/bun install --frozen-lockfile. It’s faster (skips re-resolution), reproducible, and fails loudly on lock drift. - Pin the PM with
packageManager+ corepack so “works on my machine” can’t be a resolver-version difference. Commit the field; let corepack enforce it. - Default to
--ignore-scriptsorg-wide, then allowlist the handful of deps that need native builds. Combine with--omit=dev/--omit=optionalfor prod images. - Remediate deep CVEs with the override field (
overrides/resolutions/pnpm.overrides) to force a patched transitive version while you wait for the parent to bump. - Reach for pnpm on monorepos — graph-ordered
--filter -rruns and the isolated store are the biggest practical wins; reach for Yarn PnP for strictness + zero-installs, and bun install for raw speed. - Verify provenance on critical deps (
npm audit signatures) and prefer packages published with provenance/trusted publishing.
Anti-patterns
- Not committing the lockfile (or
.gitignore-ing it) — you forfeit reproducibility and can’t audit what actually shipped. npm installin CI instead ofnpm ci— lets a stale/mismatched lock silently re-resolve and rewrite, defeating the whole point of locking.- Relying on a phantom dependency — importing a package you never declared because it hoisted; it breaks the day a transitive dep drops it. (pnpm/PnP make this an immediate error — a feature, not a nuisance.)
- Running
npm installuntrusted with scripts enabled — a singlepostinstallis RCE. Audit new deps and default to--ignore-scripts. audit fix --forcewithout reading the diff — it can pull a breaking major and quietly change your tree.- Mixing package managers in one repo (an
npm installover a pnpm project) — produces a conflicting/second lockfile and an inconsistent tree. Pin one PM viapackageManager. - Wide-open
*/latestranges or^0.xblindness — surrenders control over what resolves and widens the compromise window.
Troubleshooting
npm cifails: “lock file’s … does not satisfy … in package.json” → the lock is out of sync; runnpm installlocally to reconcile and commit the updated lock, thennpm cipasses. Never hand-edit the lock to fix this.ERESOLVEpeer-dependency conflict (npm 7+) → a peer range can’t be satisfied across the tree. Fix the real version, or useoverrides;--legacy-peer-depssuppresses the check (last resort — it ships a tree npm considers invalid).- “Cannot find module X” after switching to pnpm/Yarn PnP → X was a phantom dependency;
declare it in
package.json(PnP: or add viapackageExtensions). This is the strict layout doing its job. - Integrity /
EINTEGRITYchecksum mismatch → the downloaded tarball’s hash ≠ the lock’sintegrity. Clear the cache (npm cache clean --force), confirm the registry, and treat an unexplained mismatch as a possible tampering/lockfile-injection signal. - Different results local vs CI → almost always a PM-version drift (pin via corepack)
or
npm installvsnpm ci. Lock the resolver, not just the deps. postinstall/build step not running undernode --run→ expected:node --runskips pre/post hooks by design. Usenpm run(or call the script explicitly) when you need the hooks.
References
- npm Docs —
npm ci(reproducible install, strict lock match): https://docs.npmjs.com/cli/v11/commands/npm-ci/ - npm Docs — package-locks (
package-lock.json,integrity/SRI): https://docs.npmjs.com/cli/v6/configuring-npm/package-locks/ - npm Docs — scripts (lifecycle, pre/post hooks,
preparevsinstall): https://docs.npmjs.com/cli/v11/using-npm/scripts - npm Docs — semver / version ranges (
^,~): https://docs.npmjs.com/cli/v6/using-npm/semver/ - npm Docs — Generating provenance statements & Trusted publishers: https://docs.npmjs.com/generating-provenance-statements/ ; https://docs.npmjs.com/trusted-publishers/
- pnpm — Motivation, symlinked
node_modules, store (content-addressable + hard links): https://pnpm.io/motivation ; https://pnpm.io/symlinked-node-modules-structure - pnpm — Workspaces & the
workspace:protocol: https://pnpm.io/workspaces ; Settings (auto-install-peers,overrides): https://pnpm.io/settings - Yarn — Plug’n’Play (
.pnp.cjs, strict deps,nodeLinkerfallback): https://yarnpkg.com/features/pnp ; Workspaces (workspace:protocol): https://yarnpkg.com/features/workspaces - Yarn (Classic) — dependency versions /
resolutions: https://classic.yarnpkg.com/lang/en/docs/dependency-versions/ - Bun —
bun install(global cache, hardlink backend) & text lockfile: https://bun.com/docs/pm/cli/install ; https://bun.com/blog/bun-lock-text-lockfile - Node.js —
--run(skips pre/post lifecycle scripts; ignoresNODE_OPTIONS): https://nodejs.org/api/cli.html#--run - Node.js — Corepack & the
packageManagerfield (PM pinning): https://nodejs.org/api/corepack.html ; https://github.com/nodejs/corepack - GitHub Blog — Introducing npm package provenance (Sigstore): https://github.blog/security/supply-chain-security/introducing-npm-package-provenance/ ; Sigstore Blog — provenance GA: https://blog.sigstore.dev/npm-provenance-ga/
- OWASP / Snyk — dependency confusion &
--ignore-scriptsmitigations: https://snyk.io/blog/detect-prevent-dependency-confusion-attacks-npm-supply-chain-security/ ; https://github.com/lirantal/npm-security-best-practices