# npm (Node Package Manager)

> npm is the default package manager for Node.js: a CLI that installs packages from the public npm registry, which GitHub operates.

Also known as: npm CLI, npm install, npm package, npmjs

Source: https://socket.dev/glossary/npm-node-package-manager

Last updated: 2026-10-29

npm (Node Package Manager) is the default [package manager](https://socket.dev/glossary/package-manager) for Node.js. It is two systems: a command-line client, the npm CLI, that resolves the version ranges in [package.json](https://socket.dev/glossary/package-json), downloads tarballs, and writes them to node\_modules, and the public [npm registry](https://socket.dev/glossary/npm-registry) that serves those tarballs. Every npm install trusts whoever last published each version.

The most important fact about npm in October 2026 is a version split. [npm 12](https://socket.dev/blog/npm-12), released on July 8, 2026, no longer runs dependency install scripts unless a project approves them. Node.js has not caught up. [Node.js 24.22.0 LTS](https://nodejs.org/en/blog/release/v24.22.0), released October 14, 2026, bundles npm 11.19.1, and the Node.js Release Working Group [decided not to land npm 12](https://github.com/nodejs/Release/issues/1161) on Node.js 22, 24, or 26. Unless a team installs npm 12 on purpose, its installs still run every dependency's postinstall hook.

Our position: npm now treats installation as code execution, but only in a release most developers do not have, and even npm 12 trusts registry metadata in places where it should read the tarball. Turn the controls on explicitly, and judge a package by what is inside it, not by what the registry says about it.

## What is npm?

npm is the package manager that ships with Node.js, together with the registry and website behind it. npm's documentation describes [three components](https://docs.npmjs.com/about-npm): the npmjs.com website, the command-line interface, and the registry, a public database of packages and their metadata.

The npm CLI is the part developers touch. `npm install` adds dependencies, `npm ci` installs exactly what a lockfile records, `npm run` executes scripts from package.json, and `npx` runs a package's command without adding it to the project. The npm registry at registry.npmjs.org is an HTTP API. It serves one JSON metadata document per package name and one tarball per published version.

The npm registry outlived the company that built it. On March 16, 2020, GitHub [announced](https://github.blog/2020-03-16-npm-is-joining-github) that it had "signed an agreement to acquire npm." The registry then held over 1.3 million packages and served 75 billion downloads a month. GitHub has operated the registry since, and npm policy changes now ship through the GitHub changelog.

The npm registry is shared infrastructure. pnpm, Yarn, and Bun install packages from the same registry, with their own clients and their own defaults. A control the registry enforces protects users of every client. A control in the npm CLI protects only people running that version of the npm CLI.

## What is an npm package?

An npm package is a named, versioned tarball on the npm registry, described by a package.json file. npm's documentation defines it more broadly: "A package is a file or directory that is described by a package.json file." A module is narrower. It is any file or directory in node\_modules that Node.js can load with `require()` or `import`.

On the registry, each package name maps to one JSON document, called a packument, that lists every published version. npm requests an abbreviated form with the header `Accept: application/vnd.npm.install-v1+json`, which keeps [only the fields needed to install](https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md). This is the real entry for semver 7.8.5, a package the npm CLI team maintains, fetched on October 29, 2026:

```sh
$ curl -s -H 'Accept: application/vnd.npm.install-v1+json' https://registry.npmjs.org/semver \
    | jq '.versions["7.8.5"] | del(.devDependencies)'
{
  "name": "semver",
  "version": "7.8.5",
  "bin": {
    "semver": "bin/semver.js"
  },
  "dist": {
    "shasum": "39b646037dd50c14fb451e7e4cac58ed8b863f69",
    "tarball": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
    "fileCount": 53,
    "integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
    "signatures": [
      {
        "sig": "MEQCIA1IMvQVanynk4boyVwPRYxqwVyNO88VINzVnIEactQlAiB/YQz4fEE45VLTLw/CEEHBWzh+hzED63yilqQQZPcQ1Q==",
        "keyid": "SHA256:DhQ8wR5APBvFHLF/+Tc+AYvPOdTpcIDqOhxsBHRwC7U"
      }
    ],
    "attestations": {
      "url": "https://registry.npmjs.org/-/npm/v1/attestations/semver@7.8.5",
      "provenance": {
        "predicateType": "https://slsa.dev/provenance/v1"
      }
    },
    "unpackedSize": 101065
  },
  "engines": {
    "node": ">=10"
  }
}
```

The `dist` object carries the security model. `tarball` is where the bytes live. `integrity` is a Subresource Integrity string holding the tarball's SHA-512 hash, and `shasum` is the legacy SHA-1. `signatures` holds the registry's ECDSA P-256 signature. `attestations` points to a Sigstore provenance statement recording which CI workflow built the tarball.

Abbreviated packuments are not small. On October 29, 2026, the full packument for react measured 7.09 MB uncompressed and the abbreviated one 2.96 MB, covering 2,993 versions. The abbreviated form drops the README, publish times, and scripts. In place of the scripts it keeps one boolean, `hasInstallScript`.

The tarball is a gzipped archive with every file under a `package/` prefix, including its own package.json:

```sh
$ curl -sO https://registry.npmjs.org/semver/-/semver-7.8.5.tgz
$ tar tzf semver-7.8.5.tgz | head -3
package/LICENSE
package/range.bnf
package/functions/clean.js
$ tar tzf semver-7.8.5.tgz | wc -l
53
$ tar xzOf semver-7.8.5.tgz package/package.json | jq -c '{name, version, bin}'
{"name":"semver","version":"7.8.5","bin":{"semver":"bin/semver.js"}}
```

Every published version therefore exists twice: once as metadata in the packument, and once as the package.json inside the tarball. npm reads both copies at different steps. For semver they agree. A later section shows a package where they do not.

## How does npm install a package?

npm install turns a version range into code on disk in five steps: resolve, fetch, verify, extract, and run scripts. Figure 1 shows each step, what npm trusts there, and the setting that checks it.

> Figure 1. What npm install does with one dependency. Example values from semver 7.8.5 on October 29, 2026. Defaults checked on npm 10.9.9, 11.21.1, and 12.2.1.
>
> The five steps of npm install, what npm trusts at each step, and which setting checks it

Figure 1 in text. Step 1, resolve: npm reads a range such as `"semver": "^7.7.0"`, fetches the abbreviated packument, and picks the highest matching version, which was 7.8.5 on October 29, 2026. If package-lock.json already records a version that satisfies the range, npm uses it instead. `min-release-age` filters candidates at this step. Step 2, fetch: npm downloads the URL in `dist.tarball`. `allow-git` and `allow-remote` decide whether git and URL dependencies are fetched at all. Step 3, verify: npm compares the tarball's SHA-512 hash with the `integrity` value from the lockfile or packument, and aborts on a mismatch. `npm audit signatures` can additionally check the registry signature and provenance. Step 4, extract: npm unpacks the tarball into node\_modules and symlinks every `bin` the package's own package.json declares into node\_modules/.bin. No setting reviews bins. Step 5, run scripts: npm 10 and npm 11 run each dependency's preinstall, install, and postinstall scripts, plus an implicit node-gyp rebuild for packages that ship binding.gyp. npm 12 runs them only for packages listed in `allowScripts`. `ignore-scripts` turns them all off in every version.

The lockfile records the result of the first three steps. This is the [package-lock.json](https://socket.dev/glossary/package-lock-json) entry npm 12.2.1 wrote for esbuild 0.25.0, trimmed to the fields that matter here:

```json
"node_modules/esbuild": {
  "version": "0.25.0",
  "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.0.tgz",
  "integrity": "sha512-BXq5mqc8ltbaN34cDqWuYKyNhX8D/Z0J1xdtdQ8UcIIIyJyz+ZMKUt58tF3SrZ85jcfN/PZYhjR5uDQAYNVbuw==",
  "hasInstallScript": true,
  "bin": {
    "esbuild": "bin/esbuild"
  }
}
```

The verify step proves less than it appears to. The integrity check proves the bytes match the hash the registry published. The registry signature proves the registry published that hash. We checked both by hand for semver 7.8.5 with curl, jq, and openssl:

```sh
V=$(curl -s https://registry.npmjs.org/semver/7.8.5)
INTEGRITY=$(echo "$V" | jq -r .dist.integrity)
KEYID=$(echo "$V" | jq -r '.dist.signatures[0].keyid')
curl -s "$(echo "$V" | jq -r .dist.tarball)" -o semver.tgz
echo "sha512-$(openssl dgst -sha512 -binary semver.tgz | base64 -w0)" | grep -qx "$INTEGRITY" && echo "integrity OK"
curl -s https://registry.npmjs.org/-/npm/v1/keys \
  | jq -r --arg k "$KEYID" '.keys[] | select(.keyid==$k) | .key' \
  | base64 -d | openssl pkey -pubin -inform DER -out npm.pem
echo "$V" | jq -r '.dist.signatures[0].sig' | base64 -d > sig.der
printf 'semver@7.8.5:%s' "$INTEGRITY" | openssl dgst -sha256 -verify npm.pem -signature sig.der

# output on October 29, 2026:
integrity OK
Verified OK
```

The signed message is only `semver@7.8.5:sha512-...`: a name, a version, and a tarball hash. The signature covers no other packument field and says nothing about what the code does. The registry signs every version it accepts, including one published with a stolen credential. The worm in the [keyv and cacheable compromise](https://socket.dev/blog/popular-npm-packages-in-the-keyv-and-cacheable-namespaces-compromised-in-active-supply-chain) relied on this. It injected a preinstall hook into each package its stolen tokens could reach, recomputed the integrity and shasum fields, and republished through the victim's own identity.

## Manifest confusion: when the registry and the tarball disagree

[Manifest confusion](https://socket.dev/glossary/manifest-confusion) is a mismatch between a version's packument metadata and the package.json inside its tarball. Darcy Clarke, a former npm CLI team lead at GitHub, [disclosed it in June 2023](https://www.vlt.io/blog/the-massive-hole-in-the-npm-ecosystem). His summary: "manifests are never fully validated against the tarball's contents."

Clarke's demonstration package is still on the registry. We installed darcyclarke-manifest-pkg@2.1.15, published on March 9, 2023, with npm 12.2.1 on October 29, 2026, and compared both copies. Figure 2 shows the result.

> Figure 2. One published version, two manifests. Installed with npm 12.2.1 on October 29, 2026. Every field shown differs between the copies.
>
> The registry metadata and the tarball package.json for darcyclarke-manifest-pkg 2.1.15 disagree, and npm tools read different copies

| Field or reader | Registry packument | Tarball package.json |
| --- | --- | --- |
| name | darcyclarke-manifest-pkg | express |
| version | 2.1.15 | 3.0.0 |
| scripts | {} (no hasInstallScript flag) | install: touch ./bad-pkg-write && echo "bad pkg exec!" |
| dependencies | {} | sleepover: \* |
| package-lock.json entry | Records 2.1.15 with no hasInstallScript | Not consulted |
| npm install-scripts ls | "No packages with unreviewed install scripts." | Not consulted |
| Dependency resolution | sleepover not installed | Not consulted |
| node\_modules/darcyclarke-manifest-pkg/package.json | Not used | Written to disk as express 3.0.0 |
| npm query ':attr(scripts, \[install\])' | Not used | Returns express 3.0.0 with its install script |

npm 12.2.1 believed the registry at every decision point we observed. It resolved dependencies from the packument, so sleepover, declared only inside the tarball, never installed. It printed no install-script warning, and `npm install-scripts ls` had nothing to show a reviewer. The file it wrote to node\_modules, however, is the tarball's copy. Anything that later reads node\_modules sees a different package:

```sh
$ npm query ':attr(scripts, [install])' | jq -c '.[] | {name, version, scripts}'
{"name":"express","version":"3.0.0","scripts":{"install":"touch ./bad-pkg-write && echo \"bad pkg exec!\"\n"}}
```

No script ran in our test. We ran these installs with scripts disabled, so the result shows what npm reviewed and resolved, not what every npm version executes on every code path. The finding is narrower and still serious: npm 12's approval workflow decides what to show a reviewer from registry metadata, and registry metadata can describe a different package than the one installed.

Socket analyzes the package.json inside the tarball, the copy package managers install, and has [done so since September 5, 2022](https://socket.dev/blog/manifest-confusion). When the two copies disagree, Socket raises a [Manifest confusion](https://socket.dev/alerts/manifestConfusion) alert. Any tool that builds a dependency graph from packuments alone inherits the registry's version of events.

## Which npm security settings should you turn on?

The npm security settings that change outcomes are the install-script policy, a release-age window, restrictions on git and URL dependencies, and signature verification. Each closes one path. None of them inspects what a package's code does.

### allowScripts and ignore-scripts

npm 12 blocks dependency [install scripts](https://socket.dev/glossary/postinstall-scripts) by default and records approvals in package.json. The [GitHub changelog](https://github.blog/changelog/2026-06-09-upcoming-breaking-changes-for-npm-v12/) states it plainly: "`npm install` will no longer execute `preinstall`, `install`, or `postinstall` scripts from dependencies." A skipped script produces a warning, and the install still succeeds:

```sh
$ npm install esbuild@0.25.0
added 2 packages in 1s
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts   esbuild@0.25.0 (postinstall: node install.js)
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

$ npm install-scripts approve esbuild
Approved esbuild:
  added esbuild@0.25.0

$ jq -c .allowScripts package.json
{"esbuild@0.25.0":true}
```

Keep approvals pinned to a version. npm 12 writes `esbuild@0.25.0` by default, so the next esbuild release needs a fresh review. A name-only entry approves every future version, including one published with a stolen token. Set `strict-allow-scripts=true` in CI, so an unreviewed script fails the build instead of printing a warning nobody reads. npm 11.16 and later print the same list as a warning but still run the scripts.

`ignore-scripts=true` is the older, blunter control, and it works on npm 10 and npm 11. It skips every install script in the tree, including your own project's, and it skips the pre and post hooks around `npm run` commands. It overrides `allowScripts` entirely. The `allowScripts` field in package.json does not apply to `npx` or `npm install -g`, because neither has a project package.json. Those contexts use the `allow-scripts` config instead.

### Blocking scripts does not block bins

Install-script policy has a blind spot: the `bin` field. npm links every bin a dependency declares into node\_modules/.bin, and `npm run` puts that directory first on PATH. npm 12.2.1 still accepts a dependency that names its bin `node`. We built a local package to show it:

```sh
$ cat innocent-utils/package.json
{ "name": "innocent-utils", "version": "1.0.0", "bin": { "node": "fake-node.sh" } }

$ npm install ./innocent-utils-1.0.0.tgz
added 1 package in 529ms
$ readlink node_modules/.bin/node
../innocent-utils/fake-node.sh
$ npm install-scripts ls
No packages with unreviewed install scripts.

$ npm run check        # "check": "node --version"
this is not node: /work/app/node_modules/.bin/node --version
```

The package declared no install script, so npm 12's review flow never flagged it. Its code runs the next time any project script calls `node`. We [reported this class of bin abuse in 2022](https://socket.dev/blog/npm-bin-script-confusion), and GitHub told us the behavior works as designed. `bin-links=false` stops the linking and also breaks every dependency that ships a CLI.

### min-release-age

`min-release-age` makes npm ignore versions published fewer than a set number of days ago. npm [added it in 11.10.0](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration) on February 11, 2026, and added `min-release-age-exclude` for internally maintained packages in 11.17.0 on June 11, 2026. A [dependency cooldown](https://socket.dev/glossary/dependency-cooldown) works only if someone finds and removes a malicious version before the window ends. We resolved `"@types/node": "^24.13.0"` with npm 12.2.1 on October 29, 2026:

| min-release-age | Resolved version | Published |
| --- | --- | --- |
| not set | 24.19.2 | October 9, 2026 |
| 7 | 24.19.2 | October 9, 2026 |
| 21 | 24.19.1 | October 1, 2026 |
| 30 | 24.19.0 | September 25, 2026 |

Two behaviors matter in practice. The window filters new resolutions only: with 24.19.2 already in package-lock.json, `npm install --min-release-age=30` left it in place. An explicit request for a version inside the window fails instead of silently downgrading:

```sh
$ npm install @types/node@24.19.2 --min-release-age=30
npm error code ETARGET
npm error notarget No matching version found for @types/node@24.19.2 with a date before 9/29/2026, 3:10:49 PM.
```

npm's documentation describes a third behavior: when the window blocks the version `npm audit fix` needs, npm keeps the vulnerable version, warns, and exits non-zero. npm 10.9.9, which the Node.js 22.23.4 binary we tested bundles, has no `min-release-age`, `allow-git`, or `allowScripts` setting at all.

### npm audit signatures, allow-git, and allow-remote

`npm audit signatures` runs the check from verify.sh for every installed package and also verifies provenance attestations where they exist. Provenance is necessary but not sufficient. In the keyv and cacheable compromise, keyv@6.0.0 shipped with a passing attestation, because the maintainer's real release workflow built source that was already trojanized. The command also needs network access to Sigstore's TUF repository. In our sandbox it failed with `getaddrinfo ENOTFOUND tuf-repo-cdn.sigstore.dev` and exited 1, so a blocked network fails the check instead of passing it.

`allow-git=none` and `allow-remote=none` are npm 12 defaults and opt-in settings on npm 11. The git setting matters even under `ignore-scripts`: a git dependency's .npmrc could override the path to the git executable and run code during install, a path npm [closed behind a flag in npm 11.10.0](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration).

### npm security settings compared

| Setting | Stops | Does not stop | npm 10.9.9 | npm 11.21.1 | npm 12.2.1 |
| --- | --- | --- | --- | --- | --- |
| allowScripts | Unapproved preinstall, install, postinstall, implicit node-gyp, and non-registry prepare scripts | New code in a package approved by name only; bins; runtime code | Not available | Warns, still runs | Default |
| ignore-scripts | All install scripts, including the project's own | Bins; runtime code; on npm 10 and 11, the git .npmrc override | Opt-in | Opt-in | Opt-in, overrides allowScripts |
| min-release-age | Versions younger than the window, at resolution time | Versions already locked; malware nobody reports in time | Not available | Opt-in | Opt-in |
| allow-git=none, allow-remote=none | Git and URL dependencies, including the git .npmrc override | Registry packages | Not available | Opt-in | Default |
| npm audit signatures | Tampered tarballs; invalid provenance | Malware published with valid credentials | Available | Available | Available |
| npm ci with a committed lockfile | Silent re-resolution to new versions | npx, global installs, new dependencies; versions locked while malicious | Available | Available | Available |

Our verdict: install npm 12 explicitly instead of taking the npm that Node.js bundles. That one change matters more than any setting in this table, because it flips the default for install scripts and for git and URL dependencies. npm 12 runs on Node.js 22.22.2 or later in the 22 line, 24.15.0 or later in the 24 line, and any release from 26.0.0 on. In CI, add pinned `allowScripts`, `strict-allow-scripts=true`, `npm ci` from a committed lockfile, and a `min-release-age` of a few days. Where npm 12 is impossible, set `ignore-scripts=true`, and on npm 11 add `allow-git=none` and `allow-remote=none`. None of these controls reads a package's code. Our [npm security](https://socket.dev/glossary/npm-security) entry covers what does, and how the past year's compromises got past each control.

## What npm still trusts

npm's largest remaining trust problem sits on the publishing side. The npm registry authorizes a publish by credential: a token, a browser session, or a trusted-publishing OIDC identity. Whoever holds one publishes as the maintainer, and every client that resolves a matching range installs the result.

Three registry controls have arrived since mid-2025. [Trusted publishing](https://socket.dev/glossary/npm-trusted-publishing), which [npm rolled out to all users in 2025](https://socket.dev/blog/npm-trusted-publishing), replaces long-lived tokens with short-lived OIDC tokens minted in CI. [Publish-time malware scanning](https://github.blog/changelog/2026-07-28-npm-publish-time-malware-scanning-and-dual-use-metadata/), live since July 28, 2026, holds each new version for about five minutes before it becomes installable. [Staged publishing](https://socket.dev/blog/npm-invalidates-tokens-mini-shai-hulud) entered public preview in npm 11.15.0 in May 2026 and parks a CI publish until a human approves it with 2FA.

Each of the three assumes the attacker lacks something 2026's attackers had. In the [TanStack compromise](https://socket.dev/blog/tanstack-npm-packages-compromised-mini-shai-hulud-supply-chain-attack) of May 11, 2026, attackers extracted an OIDC token from a GitHub Actions runner's memory and published through the project's legitimate trusted-publisher binding. The keyv and cacheable compromise reached the registry on August 4, 2026, a week after publish-time scanning launched. Only staged publishing puts a human between a stolen credential and an installable version, and it is opt-in per package. GitHub [plans to remove direct publishing](https://github.blog/changelog/2026-07-08-npm-install-time-security-and-gat-bypass2fa-deprecation/) from 2FA-bypass tokens around January 2027.

We think staged publishing should be the default for every package with meaningful downloads, and that the registry should reject any publish whose packument and tarball manifest disagree. Until both happen, the decision gets made at install time on the consumer's machine, by an npm client that, in most Node.js installations, still runs every dependency's install scripts.
