Back to glossary

npm-shrinkwrap.json

npm-shrinkwrap.json is the publishable lockfile from npm shrinkwrap. npm 11 and earlier installed its pinned tree for consumers; npm v12 ignores it.

Also known as npm shrinkwrap, shrinkwrap file, publishable lockfile

npm-shrinkwrap.json, or shrinkwrap file, is the publishable lockfile created by the npm shrinkwrap command. It has the same format as package-lock.json, but npm through v11 honored it inside published packages, installing the exact tree the author recorded and ignoring consumers' overrides. npm v12 removed the command and ignores the file, so one tarball now installs two different trees.

The difference from an ordinary lockfile is who it binds. npm reads a package-lock.json only at the root of the project that commits it and ignores one found anywhere else. A shrinkwrap bound everyone downstream. When npm 11 or earlier installed a package that carried one, it skipped semver resolution for that package's entire subtree and installed exactly what the file listed, from the URLs the file listed.

We think npm was right to remove it. A publishable lockfile let a package author decide which code ran several levels down your transitive dependency tree, fetched from which URL, in a place your overrides could not reach. npm v12 shipped on July 8, 2026 and removed shrinkwrap support on both the publish side and the install side. As of November 2026, the remaining risk is version skew: tarballs already on the registry still carry shrinkwrap files, and npm 11 still honors them.

How does npm shrinkwrap work?

#

npm shrinkwrap works by turning a lockfile into a file that npm publishes and later trusts inside the consumer's tree. On npm 11 and earlier, the command renames package-lock.json to npm-shrinkwrap.json, and npm pack puts the result in the tarball. We built a one-dependency library, sw-lib, to show each step. Its package.json accepts any ms 2.x release, but its shrinkwrap pins ms 2.0.0:

Shell Session
$ npm --version
11.21.1
$ cat package.json
{ "name": "sw-lib", "version": "1.0.0", "dependencies": { "ms": "^2.0.0" } }
$ npm shrinkwrap
npm notice package-lock.json has been renamed to npm-shrinkwrap.json
$ npm pack --dry-run 2>&1 | grep -A2 'Tarball Contents'
npm notice Tarball Contents
npm notice 496B npm-shrinkwrap.json
npm notice 77B package.json
JSON
"node_modules/ms": {
  "version": "2.0.0",
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
  "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A=="
}

The npm registry advertises the file in its metadata. Every version document carries a _hasShrinkwrap boolean, which tells the installer to open the tarball before resolving anything beneath it:

Shell Session
$ curl -s https://registry.npmjs.org/@salesforce%2fcli/2.155.8 \
    | jq '{version, _hasShrinkwrap, _npmVersion}'
{
  "version": "2.155.8",
  "_hasShrinkwrap": true,
  "_npmVersion": "11.19.0"
}

Inside Arborist, npm's tree builder, a shrinkwrapped package is exempt from normal resolution. The source comment gives the reason: the package is skipped "because it's going to get its deps satisfied by whatever's in that file anyway." The pinned subtree lands under the package's own node_modules and is never hoisted. We published sw-lib to a local Verdaccio registry, set _hasShrinkwrap on its version document as the npm registry does, and installed it into an empty project. The consumer's package-lock.json on npm 11.21.1, trimmed:

JSON
"node_modules/sw-lib": {
  "version": "1.0.0",
  "resolved": "http://127.0.0.1:4873/sw-lib/-/sw-lib-1.0.0.tgz",
  "hasShrinkwrap": true
},
"node_modules/sw-lib/node_modules/ms": {
  "version": "2.0.0",
  "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz"
}

npm 12.2.1 ignores the shrinkwrap during the same install. It resolves ^2.0.0 to ms 2.1.3, hoists it to the top-level node_modules, and records no hasShrinkwrap flag.

npm 10.9.9 and 11.21.1

Shrinkwrap honored

  • app/node_modules/
  • sw-lib@1.0.0 hasShrinkwrap
  • node_modules/
  • ms@2.0.0
  • Pinned by sw-lib's npm-shrinkwrap.json. Nested, never hoisted or deduplicated.

npm 12.2.1

Shrinkwrap ignored. pnpm 10.28.0, Yarn 1.22.22 and Bun 1.4.4 also install ms 2.1.3.

  • app/node_modules/
  • ms@2.1.3
  • Resolved fresh from the ^2.0.0 range and hoisted.
  • sw-lib@1.0.0
Same tarball, two trees. sw-lib@1.0.0 declares ms ^2.0.0 and ships an npm-shrinkwrap.json pinning ms 2.0.0.

Figure 1 in text: with npm 10.9.9 and npm 11.21.1, the tree is app/node_modules/sw-lib@1.0.0, flagged hasShrinkwrap, containing node_modules/ms@2.0.0 pinned by the shrinkwrap. With npm 12.2.1, the tree is app/node_modules/ms@2.1.3, resolved from ^2.0.0 and hoisted, next to app/node_modules/sw-lib@1.0.0. In the same test, pnpm 10.28.0, Yarn 1.22.22, and Bun 1.4.4 all installed ms 2.1.3. None of them read a dependency's shrinkwrap.

What is npm-shrinkwrap.json used for?

#

npm-shrinkwrap.json is used to ship a fully pinned dependency tree with an application distributed through the npm registry, usually a CLI installed globally. npm's documentation limited it to that case and warned library authors away: "It's strongly discouraged for library authors to publish this file, since that would prevent end users from having control over transitive dependency updates."

Registry metadata shows who relied on it. As of November 2, 2026, 1,556 of the 1,652 published versions of @salesforce/cli carry _hasShrinkwrap: true. netlify-cli shipped a shrinkwrap in 1,098 versions through 24.11.3, published April 15, 2026, and dropped it in 25.0.0. serverless carried one from 4.33.3 through 4.39.0. Anyone can reproduce the count:

Shell Session
$ curl -s https://registry.npmjs.org/@salesforce%2fcli \
    | jq '[.versions[] | select(._hasShrinkwrap)] | length'
1556

The upside was real. A CLI that pinned its tree could not pick up a malicious release published after the pin. During the axios compromise, axios 1.14.1 pulled in plain-crypto-js@4.2.1, and developer CLIs that declared ranges like ^1.11.0 could resolve it on fresh installs and npx runs, as we documented in the hidden blast radius of the axios compromise. A CLI whose shrinkwrap was cut before the malicious release would have kept npm 11 users on the axios version it was tested with.

The same isolation cut the other way. When a pinned version had a known vulnerability, consumers had to wait for the author to republish, because npm would not let them patch around it. That trade-off is why npm's docs warned libraries away, and it leads directly to the security problem.

Why does a shrinkwrapped dependency bypass your controls?

#

A shrinkwrapped dependency bypasses your controls because npm 11 treats the file's resolved URL, integrity hash, and version label as facts. The package author writes all three. The integrity field proves only that the downloaded bytes match the hash the author chose. npm 11 never checks the tarball's own package.json against the name and version the shrinkwrap claims.

To show it, we published sw-lib@1.0.1 to the local registry with one change to its shrinkwrap. The ms entry still says version 2.0.0, but resolved and integrity point at the tarball for left-pad 1.3.0, a harmless stand-in. The consumer also sets an override that should force ms 2.1.3 everywhere:

Shell Session
$ npm --version
11.21.1
$ cat package.json
{"name":"app","version":"1.0.0","dependencies":{"sw-lib":"1.0.1"},"overrides":{"ms":"2.1.3"}}
$ npm install --ignore-scripts
added 2 packages in 865ms
$ npm ls --all
app@1.0.0
`-- sw-lib@1.0.1
  `-- ms@2.0.0
$ node -p "require('./node_modules/sw-lib/node_modules/ms/package.json').name"
left-pad

npm reported ms@2.0.0, exited 0, and ignored the override. The directory named ms holds left-pad. npm 10.9.9 produced the same files on disk. As our 2024 analysis of shrinkwrap risks noted, the resolved URL can point at any host, so the substitute can be any code the author wants under a name and version that look routine.

The override failure is deliberate. A comment in npm 11's Arborist edge logic states it: "this condition means we explicitly do not support overriding bundled or shrinkwrapped dependencies."

1 · Author writes

sw-lib@1.0.1 npm-shrinkwrap.json

"node_modules/ms": {
  "version": "2.0.0",
  "resolved": ".../left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-XI5MPzVN…" (left-pad's hash)
}

npm 11 fetches the author's URL. The hash matches, because the author chose both.

2 · Disk

node_modules/sw-lib/node_modules/ms/package.json

"name": "left-pad", "version": "1.3.0"

npm 11 never checks the shrinkwrap's name and version against the tarball.

3 · What the consumer sees

$ npm ls --all
sw-lib@1.0.1 › ms@2.0.0  (exit 0)
"overrides": {"ms": "2.1.3"} → ignored

Tested on npm 11.21.1 against a local registry. npm 10.9.9 behaves the same on disk. npm 12.2.1 ignores the shrinkwrap and installs ms 2.1.3.

Figure 2 in text: the author's npm-shrinkwrap.json lists node_modules/ms with version 2.0.0, a resolved URL ending in left-pad/-/left-pad-1.3.0.tgz, and left-pad's sha512 integrity hash beginning XI5MPzVN. npm 11 fetches that URL and the hash matches, because the author chose both. On disk, node_modules/sw-lib/node_modules/ms/package.json says name left-pad, version 1.3.0. The consumer sees npm ls report sw-lib@1.0.1 with ms@2.0.0 and exit code 0, and the override to ms 2.1.3 is ignored.

The consumer's own lockfile is the one place the substitution shows. It records the key node_modules/sw-lib/node_modules/ms with version 2.0.0 and a resolved URL ending in left-pad-1.3.0.tgz. A reviewer reading that diff can catch it. A software composition analysis tool that matches on name and version sees ms@2.0.0. The failure has the same shape as manifest confusion: metadata and contents disagree, and tooling believes the metadata. We analyze what is actually in a package, not what its metadata claims.

Socket raises a Shrinkwrap alert at high severity on any package that contains npm-shrinkwrap.json, so a team can warn on it or block it before install.

npm v12 removed shrinkwrap on both ends

#

npm 12.0.0, released July 8, 2026, removed the npm shrinkwrap command and stopped reading npm-shrinkwrap.json at the project root and inside dependency tarballs, according to the npm v12 changelog. Its migration advice is to rename a root shrinkwrap to package-lock.json and to use bundleDependencies to ship a locked tree. We tested each path:

Operationnpm 10.9.9 and 11.21.1npm 12.2.1
npm shrinkwrapRenames package-lock.json to npm-shrinkwrap.jsonUnknown command: "shrinkwrap"
npm pack and npm publishIncludes npm-shrinkwrap.json in the tarballLeaves it out of the tarball, with no warning
Installing a dependency that ships oneInstalls the pinned subtree, nestedIgnores it, resolves ranges, hoists
npm install in a project with only npm-shrinkwrap.jsonUses it as the lockfileIgnores it, resolves fresh, writes package-lock.json beside it, no warning
npm ci in that projectInstalls the pinned versionsFails with EUSAGE: needs an existing package-lock.json

The publish-side change is visible on the registry, and it does not depend on intent. serverless 4.39.0, published July 8, 2026 with npm 11.16.0, carries a shrinkwrap. serverless 4.40.0, published July 22 with npm 12.0.1, does not. @salesforce/cli flipped the same way between 2.155.8, published with npm 11.19.0, and 2.156.8, published with npm 12.2.1. The publisher's npm version now decides whether consumers get a pinned tree.

The root-project change is the one that breaks builds. Node.js's citgm test harness kept an npm-shrinkwrap.json at its root. Under npm 12, npm install resolved prettier from its range instead, moving from 3.6.2 to 3.9.6 and failing the project's tests, as reported in nodejs/citgm#1117. Jordan Harband replied in that thread: "That was an intentional change in v12." npm ci fails loudly in the same situation. npm install does not.

How do you update npm-shrinkwrap.json?

#

On npm 11 and earlier, you update npm-shrinkwrap.json with the same commands that update package-lock.json. Once the file exists, npm writes to it and never creates a package-lock.json:

Shell Session
$ npm --version
11.21.1
$ npm install ms@2.1.2 --package-lock-only
up to date in 487ms
$ grep -A1 '"node_modules/ms"' npm-shrinkwrap.json
    "node_modules/ms": {
      "version": "2.1.2",
$ ls
npm-shrinkwrap.json  package.json

The npm shrinkwrap command itself is needed only to convert a fresh package-lock.json. On npm 12, updating means renaming: mv npm-shrinkwrap.json package-lock.json keeps every pinned version. After the rename, npm ci on npm 12.2.1 installed ms 2.0.0, exactly as the shrinkwrap specified.

Publishers who used shrinkwrap to ship a tested tree have no equivalent replacement. Each option gives up something different:

ApproachPins transitive deps for consumersConsumer overrides applyEach dependency fetched and verified as its own registry packageWorks on npm 12
npm-shrinkwrap.jsonYes, on npm 11 and earlierNoNo: author-chosen URLs and author-chosen hashesNo
bundleDependenciesYesNoNo: code ships inside your tarball, and the consumer's lockfile marks it inBundle with no resolved URL or integrityYes
Exact versions in package.jsonDirect dependencies onlyYesYesYes
Single-file build, such as esbuild outputYes, compiled inNoNo: dependencies disappear into your codeYes

Our verdict: libraries should publish semver ranges and nothing else, which is what npm's docs always said. CLIs should pin direct dependencies exactly and accept that transitive ones float. Consumers who want protection from fresh malicious releases can set npm's min-release-age, a dependency cooldown available in npm 11.10.0 and later, as we covered when npm introduced minimumReleaseAge. Bundle only when you must ship a byte-exact tree, and expect reviewers and scanners to see one opaque package instead of many.

Clients and scanners now disagree about the same tarball

#

Shrinkwrap files already published stay inside those tarballs. 1,556 published @salesforce/cli versions are still flagged with one. npm 11 and earlier honor it on every install, while npm 12, pnpm, Yarn, and Bun build a different tree from the same bytes. A scanner that reads the shrinkwrap inside a tarball reports a tree npm 12 never builds. A scanner that ignores it misses what npm 11 actually installs. For project installs, the reliable input is the tree the client built, as recorded in the consumer's own lockfile at install time.

npm 12 did not remove publisher-controlled trees. It moved them to bundleDependencies, which gives the author the same authority over code in your tree with less to inspect: the consumer's lockfile records a bundled package with no resolved URL and no integrity hash. Nothing in npm today lets an author ship a tested tree that consumers can still see, verify, and override. That is the problem shrinkwrap tried to solve, and it is still open.