Back to glossary

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

npm (Node Package Manager) is the default package manager for Node.js. It is two systems: a command-line client, the npm CLI, that resolves the version ranges in package.json, downloads tarballs, and writes them to node_modules, and the public 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, 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, released October 14, 2026, bundles npm 11.19.1, and the Node.js Release Working Group decided not to land npm 12 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: 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 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. This is the real entry for semver 7.8.5, a package the npm CLI team maintains, fetched on October 29, 2026:

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

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

  1. 1

    Resolve the range

    "semver": "^7.7.0" → GET /semver → 7.8.5

    TrustsThe packument: the registry's list of versions and their dependencies
    Checked bymin-release-age, a committed package-lock.json
  2. 2

    Fetch the tarball

    GET /semver/-/semver-7.8.5.tgz

    TrustsThe source in dist.tarball, or a git or URL dependency
    Checked byallow-git, allow-remote
  3. 3

    Verify the bytes

    sha512(tarball) == dist.integrity

    TrustsThe hash the registry published. A valid hash says nothing about behavior.
    Checked byAlways on. npm audit signatures adds the registry signature and provenance.
  4. 4

    Extract and link bins

    node_modules/semver/ + node_modules/.bin/semver

    TrustsThe tarball's own package.json, including any bin name
    Checked byNothing reviews bins
  5. 5

    Run install scripts

    preinstall, install, postinstall, implicit node-gyp rebuild

    TrustsEvery dependency's own instructions, run as your user
    Checked bynpm 12: allowScripts onlynpm 10, 11: all runignore-scripts: none run
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.

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

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

Registry packument

GET /darcyclarke-manifest-pkg

{
  "name": "darcyclarke-manifest-pkg",  "version": "2.1.15",  "scripts": {},  "dependencies": {}}

Tarball

package/package.json

{
  "name": "express",  "version": "3.0.0",  "scripts": { "install": "touch ./bad-pkg-write && echo \"bad pkg exec!\"" },  "dependencies": { "sleepover": "*" }}
  • package-lock.json entryregistry: 2.1.15, no install script
  • npm install-scripts lsregistry: nothing to review
  • Dependency resolutionregistry: sleepover not installed
  • File written to node_modulestarball: express 3.0.0
  • npm query ':attr(scripts, [install])'tarball: has install script
Figure 2. One published version, two manifests. Installed with npm 12.2.1 on October 29, 2026. Every field shown differs between the copies.
Field or readerRegistry packumentTarball package.json
namedarcyclarke-manifest-pkgexpress
version2.1.153.0.0
scripts{} (no hasInstallScript flag)install: touch ./bad-pkg-write && echo "bad pkg exec!"
dependencies{}sleepover: *
package-lock.json entryRecords 2.1.15 with no hasInstallScriptNot consulted
npm install-scripts ls"No packages with unreviewed install scripts."Not consulted
Dependency resolutionsleepover not installedNot consulted
node_modules/darcyclarke-manifest-pkg/package.jsonNot usedWritten to disk as express 3.0.0
npm query ':attr(scripts, [install])'Not usedReturns 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:

Bash
$ 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. When the two copies disagree, Socket raises a Manifest confusion 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 by default and records approvals in package.json. The GitHub changelog 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:

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

Bash
$ 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, 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 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 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-ageResolved versionPublished
not set24.19.2October 9, 2026
724.19.2October 9, 2026
2124.19.1October 1, 2026
3024.19.0September 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:

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

npm security settings compared

#
SettingStopsDoes not stopnpm 10.9.9npm 11.21.1npm 12.2.1
allowScriptsUnapproved preinstall, install, postinstall, implicit node-gyp, and non-registry prepare scriptsNew code in a package approved by name only; bins; runtime codeNot availableWarns, still runsDefault
ignore-scriptsAll install scripts, including the project's ownBins; runtime code; on npm 10 and 11, the git .npmrc overrideOpt-inOpt-inOpt-in, overrides allowScripts
min-release-ageVersions younger than the window, at resolution timeVersions already locked; malware nobody reports in timeNot availableOpt-inOpt-in
allow-git=none, allow-remote=noneGit and URL dependencies, including the git .npmrc overrideRegistry packagesNot availableOpt-inDefault
npm audit signaturesTampered tarballs; invalid provenanceMalware published with valid credentialsAvailableAvailableAvailable
npm ci with a committed lockfileSilent re-resolution to new versionsnpx, global installs, new dependencies; versions locked while maliciousAvailableAvailableAvailable

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 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, which npm rolled out to all users in 2025, replaces long-lived tokens with short-lived OIDC tokens minted in CI. Publish-time malware scanning, live since July 28, 2026, holds each new version for about five minutes before it becomes installable. Staged publishing 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 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 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.