# 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

Source: https://socket.dev/glossary/node-modules

Last updated: 2026-11-01

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](https://socket.dev/blog/npm-12) 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](https://socket.dev/glossary/package-json) lists what you asked for. node\_modules holds what you got, including every [transitive dependency](https://socket.dev/glossary/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:

| Entry | Example | What it is |
| --- | --- | --- |
| Package directory | react/ | One unpacked tarball, with its own package.json |
| Scope directory | @types/react/ | Scoped packages nest one level under @scope/ |
| Platform package | lightningcss-linux-x64-gnu/ | A prebuilt native binary, installed only on matching os, cpu, and libc |
| .bin/ | .bin/vite | Symlinks to executables that packages declare in their bin field |
| Hidden lockfile | .package-lock.json | npm'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](https://nodejs.org/api/modules.html#loading-from-node_modules-folders) 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](https://socket.dev/glossary/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](https://github.com/npm/npm/releases/tag/v3.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](https://socket.dev/glossary/pnpm)'s default isolated layout, the same `require` fails with `MODULE_NOT_FOUND`.

> 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.
>
> The same React project on disk under npm 12 hoisted, pnpm 12 isolated, and Yarn 4 Plug'n'Play layouts

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](https://socket.dev/blog/pnpm-12) (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](https://socket.dev/glossary/yarn-package-manager) 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`:

| Layout | Default in | What node\_modules holds | App imports undeclared scheduler | Where the bytes live |
| --- | --- | --- | --- | --- |
| Hoisted copy | npm 12.2.1 | Real directories, flattened | Resolves | Copied into each project |
| Hoisted hardlink | Bun 1.4.4 | Flattened directories | Resolves | Hardlinked from ~/.bun/install/cache on Linux and Windows; cloned on macOS |
| Isolated symlinks | pnpm 12.16.1 | Symlinks into node\_modules/.pnpm | MODULE\_NOT\_FOUND | Hardlinked from the pnpm store on Linux; cloned on macOS |
| Plug'n'Play | Yarn 4.18.1 | No node\_modules | Error: "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](https://socket.dev/glossary/package-lock-json) records an `integrity` value, which the [package-lock.json documentation](https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json) 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](https://docs.npmjs.com/cli/v12/commands/npm-audit), 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](https://docs.npmjs.com/cli/v12/commands/npm-ci), 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](https://pnpm.io/settings/node-modules#packageimportmethod) says that under hardlinks, "the file in node\_modules *is* the file in the store." [Bun's documentation](https://bun.com/docs/pm/global-cache) 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](https://pnpm.io/settings/store#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](https://socket.dev/blog/axios-npm-package-compromised) of March 2026, the malicious dependency plain-crypto-js@4.2.1 ran `setup.js` from a [postinstall hook](https://socket.dev/glossary/postinstall-scripts). 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
```

> 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.
>
> What the tarball contained, what remains on disk, and what npm reports after a self-cleaning postinstall script

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](https://socket.dev/glossary/npx-npm-package-runner) or a global install never appears in either.

Other campaigns go further than cleaning up after themselves. The [is package hijack](https://socket.dev/blog/npm-is-package-hijacked-in-expanding-supply-chain-attack) of July 2025 persisted by overwriting its own `index.js` whenever it had write access. In the [Joyfill npm beta releases compromise](https://socket.dev/blog/joyfill-npm-beta-releases-compromised) 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](https://socket.dev/blog/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](https://socket.dev/alerts/hasNativeCode) 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?](https://socket.dev/blog/inside-node-modules) cited [Backstabber's Knife Collection](https://arxiv.org/abs/2005.09535), 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](https://socket.dev/blog/mini-shai-hulud-campaign-hits-red-hat-cloud-services-npm-packages) 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](https://socket.dev/blog/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.
