Back to glossary

node_modules

node_modules is the directory where npm, pnpm, and Bun unpack a project's dependencies, which Node.js searches by walking up parent folders.

Also known as node modules, node_modules folder, node modules folder, node_modules directory

node_modules (also called the node modules folder) is the directory where a JavaScript package manager unpacks every dependency a project installs, so Node.js can find them: require('react') checks ./node_modules, then each parent directory's node_modules, up to the filesystem root. It is a disposable build artifact, not a record, and it holds code from strangers that runs with your permissions.

The most important fact about node_modules is that nothing re-checks it. Package managers verify each tarball's hash when they download it, then trust whatever files sit on disk. On October 31, 2026, we appended one line of code to react/index.js in a fresh npm 12.2.1 install. npm install, npm ls, and npm rebuild all passed, and the line ran on the next require. Our position is that node_modules should be treated as untrusted, disposable output. Rebuild it from a lockfile, analyze packages before they land in it, and never read a clean node_modules as proof that nothing bad ran.

What is the node_modules folder?

#

The node_modules folder is the installed form of your dependency tree. A package.json lists what you asked for. node_modules holds what you got, including every transitive dependency those packages pulled in, plus a few files the package manager keeps for itself.

To see what actually lands on disk, we scaffolded the official Vite React template (create-vite 9.3.3) on October 31, 2026 and installed it with npm 12.2.1 on Linux x64:

Bash
$ npx create-vite@latest my-app --template react
$ cd my-app && npm install
added 24 packages, and audited 25 packages in 4s

$ ls -A node_modules
.bin .package-lock.json @oxc-project @oxlint @rolldown @types @vitejs csstype
detect-libc fdir lightningcss lightningcss-linux-x64-gnu nanoid oxlint picocolors
picomatch postcss react react-dom rolldown scheduler source-map-js tinyglobby vite

$ du -sh node_modules
62M     node_modules

The template declares 7 packages. npm installed 24 packages in 415 files, totaling 63.4 MB. Each entry has a job:

EntryExampleWhat it is
Package directoryreact/One unpacked tarball, with its own package.json
Scope directory@types/react/Scoped packages nest one level under @scope/
Platform packagelightningcss-linux-x64-gnu/A prebuilt native binary, installed only on matching os, cpu, and libc
.bin/.bin/viteSymlinks to executables that packages declare in their bin field
Hidden lockfile.package-lock.jsonnpm's cache of the installed tree, used to skip re-reading every package

Three files dominate the folder. The Rolldown, Oxlint, and Lightning CSS native binaries (.node files) total 46.4 MB, or 73% of node_modules. None of them is JavaScript.

In a React project, node_modules holds React, ReactDOM, and the build toolchain, and almost none of it reaches the browser. npm run build turned the 63.4 MB folder into a 222 KB JavaScript bundle (69 KB gzipped) containing only the code the app imports. The bundler, the linter, and their native binaries run only on developer machines and CI runners. Those are exactly the machines that hold npm tokens, cloud keys, and SSH credentials, which is why a malicious devDependency is as dangerous as a runtime one.

How does Node.js find packages in node_modules?

#

Node.js finds a package by searching node_modules directories upward from the importing file to the filesystem root. For a bare specifier like react, the Node.js modules documentation says Node "starts at the directory of the current module, and adds /node_modules," then moves to each parent in turn. The documentation states the design goal in one sentence: "This allows programs to localize their dependencies, so that they do not clash."

Node will print the exact search list for any directory:

Bash
$ cd /srv/app/src
$ node -e 'console.log(require.resolve.paths("react").join("\n"))'
/srv/app/src/node_modules
/srv/app/node_modules
/srv/node_modules
/node_modules
/home/dev/.node_modules
/home/dev/.node_libraries
/opt/node22/lib/node

The last three entries are global folders that the documentation says exist "mostly for historic reasons." If NODE_PATH is set, its directories are searched as well.

Two properties of this algorithm matter for security. The upward search is unconditional, so a node_modules directory in any parent folder is part of a project's resolution path. A writable parent can satisfy an import the project never declared, a failure we cover under phantom dependencies. Node also resolves symlinks to their real path before searching from a package's location. That one rule is what lets the same algorithm support very different on-disk layouts.

Why npm flattens node_modules

#

npm flattens node_modules because fully nested trees were too deep and too duplicated. Before npm 3, every package got its own nested node_modules, and a shared dependency was copied under each parent that used it. npm 3.0.0, released in June 2015, changed the default so dependencies are "installed maximally flat," nested only when two parents need conflicting versions. The release notes say the change would "hopefully eliminate most cases where windows users ended up with paths that were too long."

Flattening has a cost we still live with. Hoisting puts transitive packages at the top level, where any code in the project can import them. In the npm install above, the app can require('scheduler'), a package it never declared, because ReactDOM's dependency was hoisted beside it. Under pnpm's default isolated layout, the same require fails with MODULE_NOT_FOUND.

npm 12.2.1

hoisted (default)

  • node_modules/
  • .bin/vite → ../vite/bin/vite.js
  • .package-lock.json
  • react/
  • react-dom/
  • scheduler/ app can import
  • vite/
  • … 20 more packages

Real directories, copied per project.

pnpm 12.16.1

isolated (default)

  • node_modules/
  • react → .pnpm/react@19.3.1/…
  • react-dom → .pnpm/react-dom@…
  • .pnpm/
  • react-dom@19.3.1_react@…/
  • node_modules/
  • react-dom/
  • scheduler → ../../scheduler@…
  • scheduler@0.28.0/ app cannot

Files hardlinked from ~/.local/share/pnpm/store on Linux.

Yarn 4.18.1

Plug'n'Play (default)

  • .pnp.cjs resolution map
  • yarn.lock
  • .yarn/install-state.gz
  • node_modules/ none

Packages stay zipped in ~/.yarn/berry/cache, e.g. react-npm-19.3.1-….zip.

Figure 1. One React project, three on-disk layouts, installed October 31, 2026 on Linux x64. Only the hoisted layout lets the app import scheduler, a package it never declared.

Figure 1 shows the same React project under three layouts. With npm 12.2.1 (hoisted, the default), node_modules contains real directories for react, react-dom, scheduler, vite, and 20 more packages, plus .bin/vite (a symlink to ../vite/bin/vite.js) and .package-lock.json, and the app can import scheduler. With pnpm 12.16.1 (isolated, the default), the top level holds only declared packages, each a symlink into node_modules/.pnpm. There, react-dom@19.3.1_react@19.3.1/node_modules/ contains react-dom and a symlink to scheduler, so react-dom can import scheduler and the app cannot. The files themselves are hardlinked from the pnpm store at ~/.local/share/pnpm/store on Linux. With Yarn 4.18.1 (Plug'n'Play, the default), there is no node_modules at all: a .pnp.cjs resolution map points into zip archives such as react-npm-19.3.1-….zip in ~/.yarn/berry/cache.

pnpm's isolated layout is close to the symlink scheme that the Node.js documentation itself sketches under "Package manager tips." We tested all four default layouts on October 31, 2026 by asking an app with only react and react-dom declared to import scheduler:

LayoutDefault inWhat node_modules holdsApp imports undeclared schedulerWhere the bytes live
Hoisted copynpm 12.2.1Real directories, flattenedResolvesCopied into each project
Hoisted hardlinkBun 1.4.4Flattened directoriesResolvesHardlinked from ~/.bun/install/cache on Linux and Windows; cloned on macOS
Isolated symlinkspnpm 12.16.1Symlinks into node_modules/.pnpmMODULE_NOT_FOUNDHardlinked from the pnpm store on Linux; cloned on macOS
Plug'n'PlayYarn 4.18.1No node_modulesError: "tried to access scheduler, but it isn't declared"Zip archives in the Yarn cache

Our verdict: use an isolated layout for applications, because it makes the manifest a contract the installer enforces. That means pnpm's default, npm install --install-strategy=linked, or bun install --linker isolated. Fall back to hoisted only for a tool that cannot follow symlinks. Yarn Plug'n'Play is the strictest option and costs the most compatibility, since every tool must load .pnp.cjs to resolve anything.

Nothing re-verifies node_modules after install

#

Neither npm nor pnpm re-hashed installed files during a routine install in our tests. The reason is mechanical. npm's lockfile records an integrity value, which the package-lock.json documentation defines as a hash "for the artifact that was unpacked in this location." That artifact is the tarball, not the files extracted from it:

JSON
"node_modules/react": {
  "version": "19.3.1",
  "resolved": "https://registry.npmjs.org/react/-/react-19.3.1.tgz",
  "integrity": "sha512-QNhWdt7nrgVWWQG58QuGsAR3jv3Hbe4mb4vz7w2csnHavciZJgda1CPNaldF6eVxv90ud3jUXpKlHL69gO38yw==",
  "license": "MIT"
}

Once the tarball is unpacked, npm has nothing to compare a file against without downloading it again. Since npm 7, npm also skips re-reading the tree by keeping a hidden lockfile at node_modules/.package-lock.json. npm trusts it when it is at least as new as every package folder it lists. A directory's timestamp changes when entries are added, removed, or renamed, not when an existing file's contents change. Editing a file inside a package leaves the hidden lockfile looking current.

Here is the experiment from the top of this page, with paths shortened:

Bash
$ echo 'require("fs").writeFileSync("/tmp/PWNED","x");' >> node_modules/react/index.js

$ npm install
found 0 vulnerabilities
$ npm ls react
cva@0.0.0
+-- react-dom@19.3.1
| `-- react@19.3.1 deduped
`-- react@19.3.1
$ npm rebuild react
rebuilt dependencies successfully

$ node -e 'require("react")' && ls /tmp/PWNED
/tmp/PWNED

$ npm ci && tail -1 node_modules/react/index.js
}

npm audit signatures does not catch this either. According to the npm audit documentation, the registry signs each package's name, version, and dist.integrity, which is the tarball hash again. The one npm command that fixes the edit is npm ci, which, per the npm ci documentation, removes an existing node_modules before installing.

pnpm and Bun share one file across projects on Linux

#

On Linux, pnpm and Bun hardlink package files from a global store by default, so a write in one project is a write in every project. The pnpm documentation says that under hardlinks, "the file in node_modules is the file in the store." Bun's documentation lists hardlink as the default backend on Linux and Windows and clonefile on macOS.

We appended a line to react/index.js in one pnpm 12.16.1 project. It appeared immediately in a second project that also used react@19.3.1, because both paths pointed to the same inode. pnpm install finished in 2 ms in each project and changed nothing. Only an explicit store check noticed:

Bash
$ pnpm store status
  × Packages in the store have been mutated
  │ These packages are modified:
  │ react@19.3.1

A later install that freshly linked react from the store restored the original bytes in place, in both projects at once. pnpm's verifyStoreIntegrity setting, on by default, re-checks a modified store file before linking it. pnpm can do this because it records a content hash for every file in its store, not just for every tarball. Bun 1.4.4 showed the same shared inode across two projects. On macOS, both tools default to copy-on-write clones, so an edit stays in one project.

Malware rewrites node_modules after it runs

#

Malware treats node_modules as writable scratch space, because any code running inside it can write to it. In the Axios compromise of March 2026, the malicious dependency plain-crypto-js@4.2.1 ran setup.js from a postinstall hook. The script then deleted itself, deleted its own package.json, and renamed a bundled package.md to package.json, a clean copy of version 4.2.0. Afterward, the installed folder looked like an ordinary copy of crypto-js. The package was published at 23:59:12 UTC on March 30, and Socket's scanner flagged it at 00:05:41 UTC on March 31.

We rebuilt that trick as a local package and installed it with npm 12.2.1, approving its install script with npm install-scripts approve, the way a team approves a native module:

Bash
$ ls node_modules/plain-demo
package.json
$ cat node_modules/plain-demo/package.json
{"name":"plain-demo","version":"4.2.0"}

$ grep -A1 '"node_modules/plain-demo"' package-lock.json
    "node_modules/plain-demo": {
      "version": "4.2.1",

$ npm ls
pcapp@ /tmp/pcapp
`-- plain-demo@4.2.1

Tarball that ran

package.json 4.2.1
postinstall: node setup.js
setup.js
package.md 4.2.0

payload executes

node_modules/plain-demo now

package.json 4.2.0
no scripts
setup.js
package.md

looks clean

What npm reports

package-lock.json 4.2.1
.package-lock.json 4.2.1
npm ls: plain-demo@4.2.1, exit 0

from records, not disk
Figure 2. Our reproduction of the plain-crypto-js cleanup with npm 12.2.1. The files on disk and npm's own records disagree, and nothing in a routine install notices.

Figure 2 shows the three views side by side. The tarball that ran contained package.json at version 4.2.1 with a postinstall script, setup.js, and a package.md at version 4.2.0. After the script ran, the folder on disk held only a package.json at version 4.2.0 with no scripts. package-lock.json, the hidden .package-lock.json, and npm ls all still report 4.2.1, with npm ls exiting 0. npm's records describe the tree npm installed, and npm ls trusted the hidden lockfile over the files. When we deleted the hidden lockfile, npm ls read the files instead, reported plain-demo@4.2.0 as invalid against the 4.2.1 tarball we had installed, and exited with ELSPROBLEMS. Had the dependency been declared with a registry range such as ^4.2.0, the swapped version would have satisfied it and npm ls would have passed. Inside a project, the lockfile and its tarball hash are better evidence than node_modules, though not proof: anything run through npx or a global install never appears in either.

Other campaigns go further than cleaning up after themselves. The is package hijack of July 2025 persisted by overwriting its own index.js whenever it had write access. In the Joyfill npm beta releases compromise of July 2026, the payload injected a self-reloading block into the global npm CLI at node_modules/npm/lib/cli.js. That is a node_modules directory outside any project, and one that every npm command loads.

Code in node_modules runs without install scripts

#

Install scripts are the loudest way that code in node_modules runs, and npm v12 turned them off by default. Here is npm 12.2.1 refusing to run pnpm's own installer during a global install:

Bash
$ npm i -g pnpm@12.16.1
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   pnpm@12.16.1 (preinstall: node install.js; postinstall: node install.js)

pnpm blocked install scripts by default in version 10. We think default-on lifecycle scripts were a design mistake, and that both defaults are now correct. Blocking scripts narrows when node_modules code runs. It does not change what is in node_modules, and three other paths still execute it.

The first path is .bin. We packed a local package whose bin field claimed the name node and installed it with npm 12.2.1. npm created node_modules/.bin/node, and because npm run puts .bin first on PATH, a build script that called node ran the package's shell script instead. No install script was involved. We described this as npm bin script confusion in 2022, and GitHub's response then was that the behavior is working as designed.

The second path is native code. The 46.4 MB of .node binaries in our React install arrived through optionalDependencies, with no script, and load into the Node.js process the moment a JavaScript file requires them. A scanner that reads only JavaScript cannot see inside them, which is why Socket's Native code alert flags packages that ship compiled binaries.

The third path is plain import. Every JavaScript file in node_modules executes with your permissions when something requires it. Our 2022 post What's Really Going On Inside Your node_modules Folder? cited Backstabber's Knife Collection, a 2020 study of 174 malicious packages, which found that 56% started their routines at install time. The other 44% ran later, when code imported them or a command invoked them. That is why we analyze what is actually inside a package before it is installed, rather than trusting its metadata or the install hooks it declares.

Can you delete node_modules?

#

Yes, you can delete node_modules at any time, because it is derived from package.json and the lockfile. Rebuild it with the frozen-lockfile command for your package manager, which installs exactly the versions in the lockfile or fails:

Bash
npm ci                            # removes node_modules first, then installs
pnpm install --frozen-lockfile
bun install --frozen-lockfile
yarn install --immutable

Deleting node_modules has three costs. Without a committed lockfile, the rebuild resolves fresh and takes the newest version in every range, which is how axios@1.14.1 reached projects that had never changed their code. Install scripts you approved run again, with the same access they had the first time, and under npm 12 any native build you did not approve is skipped and fails at runtime rather than at install. And every package is fetched again, from a cache if you have one.

Deleting node_modules also leaves two things in place. Package caches survive: ~/.npm/_cacache, the pnpm store, and ~/.bun/install/cache can still hold a malicious tarball and hand it back on the next install. Anything a payload already did survives too. Our guidance for the Mini Shai-Hulud wave against Red Hat packages was that "deleting node_modules should not be considered sufficient cleanup," because the malware includes background execution and potential developer-tool persistence.

Do not commit node_modules either. Our install contained lightningcss-linux-x64-gnu and @rolldown/binding-linux-x64-gnu, binaries that will not load on macOS or Windows. node_modules is specific to one operating system, CPU, and libc, while the lockfile records the choices for every platform.

A clean node_modules after an incident is weak evidence. Code you ran through npx or a global install never appears in a project's node_modules or lockfile, as we showed in The Hidden Blast Radius of the Axios Compromise. A package that ran and then tidied up looks exactly like one that never ran.

The verification gap

#

npm records one hash per tarball, so it cannot answer the simplest forensic question about node_modules: do these files match what the registry served? pnpm can answer it, because its store hashes every file. We think npm should record per-file hashes when it extracts a package and ship a command that re-checks them, the way pnpm store status does. Even that would only detect drift, not prevent it, since code that runs inside node_modules can also edit the records. Until package managers can prove what is on disk, the only node_modules you should trust is one you just rebuilt from a lockfile, from packages that were analyzed before they arrived.