# package-lock.json

> package-lock.json is npm's lockfile: it records the exact version, tarball URL, and SHA-512 hash of every installed package so installs are reproducible.

Also known as: package lock, package lock json, npm lockfile, npm lock file

Source: https://socket.dev/glossary/package-lock-json

Last updated: 2026-11-02

package-lock.json, npm's lockfile, is a generated file that records the exact version, tarball URL, and SHA-512 integrity hash of every package in a project's node\_modules tree. `npm ci` installs exactly that tree, so every machine gets the same bytes. The integrity hash proves the bytes match the lockfile, not that the lockfile is honest.

The file answers one question: which bytes go in which directory. Each entry is keyed by its install path, such as `node_modules/ms`, and carries a version label, a `resolved` URL, and an `integrity` hash. npm fetches the URL and rejects the bytes if their hash differs. During install, npm never checks the tarball's own name and version against the label. In our tests on npm 11.21.1 and 12.2.1, an entry labeled `ms@2.1.3` installed left-pad, and `npm ls` still printed `ms@2.1.3`.

We think package-lock.json deserves the review that code gets, and the tooling works against it. GitHub hides its diff by default. Every tool that reads versions from it inherits whatever it claims. A committed lockfile makes installs repeatable. It does not make them safe, and it does not cover what you run outside it.

## What is package-lock.json?

package-lock.json is the file npm writes whenever it changes `node_modules` or [package.json](https://socket.dev/glossary/package-json). package.json declares acceptable ranges, such as `"debug": "^4.3.0"`. package-lock.json records what those ranges resolved to on one day, including every [transitive dependency](https://socket.dev/glossary/transitive-dependency). It is npm's implementation of a [lockfile](https://socket.dev/glossary/lockfile); pnpm, Yarn, and Bun each write their own format.

Here is the complete file npm 12.2.1 wrote for a project with that single dependency:

```json
{
  "name": "app",
  "version": "1.0.0",
  "lockfileVersion": 3,
  "requires": true,
  "packages": {
    "": {
      "name": "app",
      "version": "1.0.0",
      "dependencies": { "debug": "^4.3.0" }
    },
    "node_modules/debug": {
      "version": "4.4.3",
      "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz",
      "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==",
      "license": "MIT",
      "dependencies": { "ms": "^2.1.3" },
      "engines": { "node": ">=6.0" },
      "peerDependenciesMeta": { "supports-color": { "optional": true } }
    },
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
      "license": "MIT"
    }
  }
}
```

The keys under `packages` are filesystem paths, not package names. `""` is the project itself. `node_modules/ms` means "the directory at that path," so the lockfile encodes the hoisted layout of the tree as well as its versions. A nested entry such as `node_modules/a/node_modules/ms` is how the file records two versions of one package.

Each entry carries three fields that matter for security:

- `version` is the label npm reports for that directory.
- `resolved` is where npm fetches the tarball. The [npm documentation](https://docs.npmjs.com/cli/v12/configuring-npm/package-lock-json) calls `registry.npmjs.org` "a magic value meaning the currently configured registry," so a mirror can serve the same lockfile.
- `integrity` is a [Subresource Integrity](https://socket.dev/glossary/sub-resource-integrity-sri) string: the algorithm, a dash, and the base64 digest of the tarball.

The rest is copied from each package's own package.json so npm can plan an install without downloading manifests. One copied field is a security signal. `hasInstallScript: true` marks a package with a `preinstall`, `install`, or `postinstall` script. Our test lockfile for core-js 3.45.1 carries the flag. [Install scripts](https://socket.dev/glossary/postinstall-scripts) are where most npm supply chain attacks execute, and the flag tells a reviewer which entries declare install-time code. The flag is itself a claim in the file, written by whoever wrote the entry.

`lockfileVersion` tracks the format. Version 1 came with npm 5 and 6. Version 2, used by npm 7 and 8, carried both old and new data for compatibility. Version 3 has been the default since npm 9 and drops the legacy `dependencies` section. npm 7 also added a hidden lockfile at `node_modules/.package-lock.json`, a cache of the installed tree that npm trusts instead of rereading every package. That cache matters again in the detection section below.

## npm install versus npm ci

`npm install` treats package-lock.json as a strong suggestion. The [npm install documentation](https://docs.npmjs.com/cli/v12/commands/npm-install) puts the rule this way: "When the lockfile's versions satisfy the `package.json` ranges, the lockfile wins. When they conflict, `package.json` wins and the lockfile is updated." Adding a dependency or editing a range therefore resolves fresh versions and rewrites the file.

`npm ci` treats package-lock.json as an order. It requires the file to exist, deletes `node_modules` before installing, never writes to either manifest, and fails if the two disagree. We changed the range to `^3.2.0` without updating the lockfile:

```console
$ npm ci
npm error code EUSAGE
npm error `npm ci` can only install packages when your package.json and package-lock.json are in sync. Please update your lock file with `npm install` before continuing.
npm error Invalid: lock file's debug@4.4.3 does not satisfy debug@3.2.7
```

Use `npm ci` in CI and in deploy builds. Use `npm install` when you intend to change the tree, and review the lockfile diff it produces.

The lockfile only governs installs inside the project that committed it. [npx](https://socket.dev/glossary/npx-npm-package-runner) uses a locally installed copy when one exists. Otherwise npx, `npm exec`, and `npm install -g` resolve their trees against the registry at run time. npm also never ships a lockfile inside a published package: the [package.json `files` documentation](https://docs.npmjs.com/cli/v12/configuring-npm/package-json#files) lists package-lock.json among the files that "cannot be included." The publishable variant, [npm-shrinkwrap.json](https://socket.dev/glossary/npm-shrinkwrap-json), is ignored by npm 12.

Those gaps decided who was exposed in [the axios compromise](https://socket.dev/blog/axios-npm-package-compromised) on March 31, 2026, when axios 1.14.1 pulled in the malicious plain-crypto-js@4.2.1. Projects whose lockfile already pinned an earlier axios, and that installed with `npm ci`, were likely protected. CLIs launched through npx resolved their dependencies fresh, and in one CI pipeline we documented, that pulled in the malicious package. Darcy Clarke, a former npm engineering manager, summarized it in our analysis of [the hidden blast radius of the axios compromise](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise): "Lockfiles don't prevent net-new installs when updating/adding new dependencies. That's the point."

A clean package-lock.json checked after an incident proves less than it seems. It shows what this project installs today. It does not show what npx fetched during the attack window, and the registry has since removed the malicious version.

## Should you commit package-lock.json?

Yes, you should commit package-lock.json for any application, service, or tool you deploy or run in CI. The npm documentation says the file "is intended to be committed into source repositories." Without it, two runs of `npm install` hours apart can build different trees, and nobody can reconstruct which one ran.

Libraries are the debated case, and the trade-off is real. A library's lockfile never reaches its users, because npm will not publish it. Committing it makes the library's own CI repeatable. It also means CI tests a tree that users may not get: a new transitive release can break consumers while your pinned CI stays green.

| Project | Commit package-lock.json? | What it protects | What it does not cover |
| --- | --- | --- | --- |
| Application or service | Yes. Install with npm ci. | Every build installs the reviewed tree. | New dependencies, npx and global tools, a tree that was bad when locked. |
| Library published to npm | Yes, for your own CI. | Reproducible tests and bisects. | Your users, who resolve your ranges fresh. |
| CLI published to npm | Yes, but it does not ship. | Your release builds. | Users installing with npm install -g or npx. Pin direct dependencies exactly, or use bundleDependencies. |
| Monorepo with workspaces | Yes, one file at the root. | Every workspace shares one resolved tree. | Users of workspace packages you publish, who resolve fresh. |

Our verdict: commit it for anything you deploy or run. For a library it is optional, and useful. A library that commits one should also run a scheduled CI job that installs with `npm install --no-package-lock`, which resolves the ranges as a new user would. That job breaks when a new upstream release breaks you, which is the signal the lockfile hides.

Socket flags the opposite failure. Since June 16, 2026, [Socket's Manifest Alerts](https://socket.dev/blog/introducing-manifest-alerts) report application repositories that contain a package.json with no matching lockfile. The alert targets applications; it does not imply that published libraries must commit one.

## What does the integrity hash in package-lock.json prove?

The integrity hash in package-lock.json proves that the tarball npm downloads is byte-for-byte the one recorded at lock time. It is the SHA-512 digest of the `.tgz` file, base64-encoded, and anyone can recompute it:

```console
$ curl -sO https://registry.npmjs.org/ms/-/ms-2.1.3.tgz
$ echo "sha512-$(openssl dgst -sha512 -binary ms-2.1.3.tgz | base64)"
sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
$ npm view ms@2.1.3 dist.integrity
sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==
```

On the public registry, that pin holds permanently. The [npm unpublish policy](https://docs.npmjs.com/policies/unpublish) states that "Once `package@version` has been used, you can never use it again." A given name and version can therefore have only one valid hash. A mismatch means the bytes came from somewhere else, and npm stops. We pointed an entry's `resolved` URL at a different tarball and left the hash alone:

```console
$ npm ci
npm warn tarball tarball data for ms@https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz (sha512-6Flz…WRTlA==) seems to be corrupted. Trying again.
npm error code EINTEGRITY
npm error sha512-6Flz…WRTlA== integrity checksum failed when using sha512: wanted sha512-6Flz…WRTlA== but got sha512-XI5M…CoEA==. (3619 bytes)
```

The hash is also npm's cache key. npm's cache is content-addressed, so on a machine that had already cached ms 2.1.3, npm 12 installed the real ms from the same edited entry without fetching the URL at all. The hash, not the URL, is the identity npm trusts.

The integrity hash leaves three things unproven:

- **The package was safe when it was locked.** Lock axios 1.14.1 during the attack window, and every later `npm ci` that can still fetch it installs the malware with a verified hash.
- **The bytes are the package the entry names.** npm checks the hash against the bytes. It does not check the bytes against the label.
- **The hash exists at all.** We deleted the `integrity` field from an edited entry. npm 11 and 12 installed the substitute without any check, and a later `npm install` on npm 12 did not restore the field.

## How does lockfile poisoning work in package-lock.json?

Lockfile poisoning is an attack that edits a package-lock.json entry so `npm ci` installs different code under a trusted name and version. The attacker changes `resolved` and `integrity` together and leaves `version` and the key alone. package.json does not change, so the pull request looks like routine lockfile churn.

Review rarely catches it. GitHub's Linguist library [classifies package-lock.json as generated](https://github.com/github-linguist/linguist/blob/main/lib/linguist/generated.rb), and generated files are hidden in pull request diffs by default. A routine dependency update can change hundreds of lines, so one more changed hash does not stand out.

Attackers already automate the delivery channel. [SANDWORM\_MODE](https://socket.dev/blog/sandworm-mode-npm-worm-ai-toolchain-poisoning), a worm we analyzed in February 2026, injects a dependency into repositories it can write to, patches package-lock.json to match, and, when it cannot push directly, opens a pull request with a routine-sounding title and tries to auto-merge it. That worm added a new package name a reviewer could spot. A swapped `resolved` URL changes no names at all.

We built the attack against a one-dependency project. The edited entry still claims to be ms 2.1.3, but `resolved` and `integrity` now describe left-pad 1.3.0, a harmless stand-in for a payload:

```json
"node_modules/ms": {
  "version": "2.1.3",
  "resolved": "https://registry.npmjs.org/left-pad/-/left-pad-1.3.0.tgz",
  "integrity": "sha512-XI5MPzVNApjAyhQzphX8BkmKsKUxD4LdyK24iZeQGinBN9yTQT3bFlCBy/aVx2HrNcqQGsdot8ghrjyrvMCoEA==",
  "license": "MIT"
}
```

```console
$ npm --version
12.2.1
$ npm ci
added 2 packages in 316ms
$ node -p "require('./node_modules/ms/package.json').name"
left-pad
$ npm ls --all
app@1.0.0 /srv/app
`-- debug@4.4.3
  `-- ms@2.1.3
```

The install exits 0, and so does `npm ls`. Figure 1 shows why: every control npm runs checks a different field, and none of them compares the extracted package with the label.

> Figure 1. One lockfile entry's path through npm ci , tested on npm 11.21.1 and 12.2.1. Four controls check release age, the host, the bytes, and the scripts. None checks that the bytes are the package the lockfile names.
>
> The path one package-lock.json entry takes through npm ci, and which control checks which field. Nothing compares the tarball's own name and version to the lockfile's label.

Figure 1 in text: an entry moves through seven steps during `npm ci`. Step 1, resolving a range to a version, happens only when the lockfile has no usable entry, and it is the only place npm's [`min-release-age` cooldown](https://socket.dev/blog/npm-introduces-minimumreleaseage-and-bulk-oidc-configuration) acts. Step 2 reads the entry: key `node_modules/ms`, version 2.1.3, resolved URL, integrity hash. Step 3 fetches the resolved URL; npm 12 rejects hosts other than the configured registry with `EALLOWREMOTE`, and npm 11 fetches any host. Step 4 hashes the bytes and stops with `EINTEGRITY` on a mismatch, but checks nothing if the field is missing. Step 5 extracts the tarball into `node_modules/ms`, and nothing compares the tarball's package.json, which may say left-pad 1.3.0, with the key or the version label. Step 6 decides on install scripts; npm 12 matches `allowScripts` against the extracted package.json. Step 7 copies the lockfile's labels into `node_modules/.package-lock.json`, which `npm ls` reads to print `ms@2.1.3` with exit code 0.

We ran six variants of the edit through `npm ci`. The failing rows used an empty cache, since a warm cache can satisfy the hash without a fetch. npm 10.9.9 behaved like npm 11 on the foreign-host variant, the only one we ran on it.

| Edit to the node\_modules/ms entry | npm 11.21.1 | npm 12.2.1 |
| --- | --- | --- |
| resolved points at another tarball, integrity unchanged | Fails: EINTEGRITY | Fails: EINTEGRITY |
| resolved on another host (registry.yarnpkg.com), matching integrity | Installs left-pad as ms@2.1.3 | Fails: EALLOWREMOTE |
| resolved on registry.npmjs.org, another package, matching integrity | Installs left-pad as ms@2.1.3 | Installs left-pad as ms@2.1.3 |
| resolved on registry.npmjs.org, older ms 2.0.0, matching integrity | Installs ms 2.0.0 as ms@2.1.3 | Installs ms 2.0.0 as ms@2.1.3 |
| resolved swapped, integrity deleted | Installs left-pad, no check | Installs left-pad, no check |
| Swapped to core-js 3.45.1 (has postinstall), with allowScripts: {"ms@2.1.3": true} | Not tested | Installs core-js, blocks its script |

npm 12 closed the foreign-host variant through a default aimed at URL dependencies. [npm v12](https://socket.dev/blog/npm-12), released July 8, 2026, set `allow-remote` to `none` by default to stop dependencies that point at arbitrary tarball URLs. npm 12 treats a foreign-host `resolved` URL the same way: it refused to fetch `ms@https://registry.yarnpkg.com/left-pad/-/left-pad-1.3.0.tgz`. The [allow-remote documentation](https://docs.npmjs.com/cli/v12/using-npm/config#allow-remote) states the boundary: "Tarballs that share a hostname with the configured registry ... are still installed normally."

That boundary leaves the attack intact. Anyone can publish a package to registry.npmjs.org, so an attacker publishes the payload under a throwaway name and points `resolved` at it. The downgrade variant needs no publishing at all: it pins an old release of the same package, perhaps one with a known vulnerability, while any scanner that reads versions from the lockfile reports the current one.

npm 12's script gate held. npm matched `allowScripts` against core-js@3.45.1, the package actually on disk, so approving `ms@2.1.3` did not approve the substitute's `postinstall`. With dependency install scripts off by default, a poisoned entry no longer runs code during install unless someone approves the real package. It still runs when the application imports it.

Lockfile poisoning is the same failure as [manifest confusion](https://socket.dev/glossary/manifest-confusion): metadata and contents disagree, and the tooling believes the metadata. We analyze what is actually in a package, not what its metadata claims, and npm should too. npm 12 already reads the extracted package.json to enforce `allowScripts`. Comparing that manifest's name and version with the lockfile entry would close every same-registry variant in the table, at the cost of one string comparison per package. Aliased dependencies already record their real name in the entry's `name` field, so aliases need no exception.

## Detecting a tampered package-lock.json

Each check for a tampered package-lock.json catches part of the problem, and each costs something.

**Restrict hosts and names with lockfile-lint.** The open source [lockfile-lint](https://github.com/lirantal/lockfile-lint) validates that every `resolved` URL uses HTTPS, points at an allowed host, and contains the package's own name. Version 5.1.0 caught the left-pad swap, and its `--validate-integrity-strict` option caught an entry with no hash. No option caught the same-name downgrade to ms 2.0.0, because the URL still names ms.

```console
$ npx lockfile-lint --path package-lock.json --type npm \
    --allowed-hosts npm --validate-https --validate-package-names \
    --validate-integrity-strict
detected resolved URL for package with a different name: ms
    expected: ms
    actual: left-pad
 ✖ Error: security issues detected!
```

**Compare every entry with the registry.** Because a public name and version has exactly one valid tarball, the registry can arbitrate. This script asks registry.npmjs.org for each entry's name and version and compares both `integrity` and `resolved`:

```js
import { readFileSync } from 'node:fs'
const lock = JSON.parse(readFileSync('package-lock.json', 'utf8'))
let bad = 0
for (const [path, e] of Object.entries(lock.packages)) {
  if (!path || e.link || e.inBundle) continue
  const name = e.name ?? path.slice(path.lastIndexOf('node_modules/') + 13)
  const res = await fetch(`https://registry.npmjs.org/${name.replace('/', '%2f')}/${e.version}`)
  const dist = res.ok ? (await res.json()).dist : {}
  const problems = []
  if (!e.integrity) problems.push('no integrity')
  else if (e.integrity !== dist.integrity) problems.push('integrity differs from registry')
  if (e.resolved !== dist.tarball) problems.push(`resolved is ${e.resolved}`)
  if (problems.length) { bad++; console.log(`${path}@${e.version}: ${problems.join('; ')}`) }
}
process.exit(bad ? 1 : 0)
```

```console
$ node verify-lock.mjs
node_modules/debug@4.4.3: no integrity
node_modules/ms@2.1.3: integrity differs from registry; resolved is https://registry.npmjs.org/ms/-/ms-2.0.0.tgz
```

The output above comes from a lockfile with a downgraded ms and a deleted hash on debug. The script flagged all three tampered lockfiles we gave it and passed the clean one. The cost is one registry request per package, and it assumes the public registry; git, file, and private-registry entries need their own rules. It also trusts the registry. A malicious version that is still published will pass, because the lockfile and the registry agree.

**Ask npm to read the real manifests.** `npm ls` trusts the hidden lockfile, which `npm ci` filled with the poisoned labels. Delete it, and npm reads every package.json on disk:

```console
$ rm node_modules/.package-lock.json
$ npm ls --all
app@1.0.0 /srv/app
`-- debug@4.4.3
  `-- ms@npm:left-pad@1.3.0 invalid: "^2.1.3" from node_modules/debug
npm error code ELSPROBLEMS
```

This check costs nothing, but it runs after the code is on disk.

**Do not expect a cooldown to help.** A [dependency cooldown](https://socket.dev/glossary/dependency-cooldown) filters versions during resolution, and `npm ci` does not resolve. With `--min-release-age=36500`, a hundred-year window, `npm ci` still installed debug 4.4.3 from the lockfile, while a fresh `npm install` without a lockfile failed with `ENOVERSIONS`. A cooldown decides what enters package-lock.json. Review decides whether what is already in it is true.

package-lock.json is the main durable record of what a JavaScript project installs, and npm treats every version string in it as a fact. Until npm compares the label with the bytes, each tool that reads package-lock.json, from `npm ls` to a software composition analysis scanner, reports what the file claims. That includes the version a reviewer approved and the one an attacker swapped in.
