# 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

Source: https://socket.dev/glossary/npm-shrinkwrap-json

Last updated: 2026-11-02

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](https://socket.dev/glossary/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](https://socket.dev/blog/npm-12) removed the command and ignores the file, so one tarball now installs two different trees.

The difference from an ordinary [lockfile](https://socket.dev/glossary/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](https://socket.dev/glossary/semantic-versioning-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](https://socket.dev/glossary/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:

```console
$ 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](https://socket.dev/glossary/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:

```console
$ 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](https://github.com/npm/cli/blob/v11.21.1/workspaces/arborist/lib/arborist/build-ideal-tree.js) 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.

> 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.
>
> The same sw-lib tarball installs two different trees: npm 10 and 11 nest ms 2.0.0 from the shrinkwrap; npm 12 hoists ms 2.1.3 from the semver range, and pnpm, Yarn and Bun also install ms 2.1.3.

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](https://docs.npmjs.com/cli/v11/configuring-npm/npm-shrinkwrap-json) 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:

```console
$ 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](https://socket.dev/blog/axios-npm-package-compromised), axios 1.14.1 pulled in [plain-crypto-js@4.2.1](https://socket.dev/blog/axios-npm-package-compromised), 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](https://socket.dev/blog/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:

```console
$ 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](https://socket.dev/blog/understanding-the-security-concerns-of-npm-shrinkwrap) 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](https://github.com/npm/cli/blob/v11.21.1/workspaces/arborist/lib/edge.js) states it: "this condition means we explicitly do not support overriding bundled or shrinkwrapped dependencies."

> 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.
>
> A shrinkwrap entry names ms 2.0.0 but points resolved and integrity at the left-pad 1.3.0 tarball. npm 11 verifies the hash, writes left-pad into the ms directory, reports ms@2.0.0, and ignores the consumer's override.

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](https://socket.dev/glossary/software-composition-analysis) tool that matches on name and version sees ms@2.0.0. The failure has the same shape as [manifest confusion](https://socket.dev/glossary/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](https://socket.dev/alerts/shrinkwrap) 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](https://docs.npmjs.com/cli/v12/using-npm/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:

| Operation | npm 10.9.9 and 11.21.1 | npm 12.2.1 |
| --- | --- | --- |
| npm shrinkwrap | Renames package-lock.json to npm-shrinkwrap.json | Unknown command: "shrinkwrap" |
| npm pack and npm publish | Includes npm-shrinkwrap.json in the tarball | Leaves it out of the tarball, with no warning |
| Installing a dependency that ships one | Installs the pinned subtree, nested | Ignores it, resolves ranges, hoists |
| npm install in a project with only npm-shrinkwrap.json | Uses it as the lockfile | Ignores it, resolves fresh, writes package-lock.json beside it, no warning |
| npm ci in that project | Installs the pinned versions | Fails 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](https://github.com/nodejs/citgm/issues/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:

```console
$ 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:

| Approach | Pins transitive deps for consumers | Consumer overrides apply | Each dependency fetched and verified as its own registry package | Works on npm 12 |
| --- | --- | --- | --- | --- |
| npm-shrinkwrap.json | Yes, on npm 11 and earlier | No | No: author-chosen URLs and author-chosen hashes | No |
| bundleDependencies | Yes | No | No: code ships inside your tarball, and the consumer's lockfile marks it inBundle with no resolved URL or integrity | Yes |
| Exact versions in package.json | Direct dependencies only | Yes | Yes | Yes |
| Single-file build, such as esbuild output | Yes, compiled in | No | No: dependencies disappear into your code | Yes |

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](https://socket.dev/glossary/dependency-cooldown) available in npm 11.10.0 and later, as we covered when [npm introduced minimumReleaseAge](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration). 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.
