Back to glossary

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

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. 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. It is npm's implementation of a 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 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 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 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 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:

Shell Session
$ 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 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 lists package-lock.json among the files that "cannot be included." The publishable variant, npm-shrinkwrap.json, is ignored by npm 12.

Those gaps decided who was exposed in the axios compromise 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: "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.

ProjectCommit package-lock.json?What it protectsWhat it does not cover
Application or serviceYes. 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 npmYes, for your own CI.Reproducible tests and bisects.Your users, who resolve your ranges fresh.
CLI published to npmYes, 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 workspacesYes, 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 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:

Shell Session
$ 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 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:

Shell Session
$ 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, 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, 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"
}
Shell Session
$ 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.

  1. Resolve a range to a version

    Only when the lockfile has no usable entry. min-release-age acts here, so it never re-checks versions already locked.

    npm 11.x+: cooldown
  2. Read the lockfile entry

    "node_modules/ms": version 2.1.3, resolved URL, integrity hash. Written by the last npm install, or by whoever edited the pull request.

  3. Fetch the resolved URL

    npm 12 rejects hosts other than the configured registry with EALLOWREMOTE. npm 11 fetches any host.

    npm 12: allow-remote checks host
  4. Hash the bytes

    SHA-512 of the tarball must equal integrity, or npm stops with EINTEGRITY. If the field is missing, nothing is checked.

    all versions: integrity checks bytes
  5. Extract into node_modules/ms

    The tarball's own package.json may say left-pad 1.3.0. npm does not compare it with the key or the version label.

    never checked: name and version
  6. Decide on install scripts

    npm 12 matches allowScripts against the extracted package.json, so approving ms@2.1.3 did not approve the swapped package's script.

    npm 12: scripts off by default
  7. Record and report

    npm copies the lockfile's labels into node_modules/.package-lock.json. npm ls reads that file and prints ms@2.1.3, exit code 0.

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.

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 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 entrynpm 11.21.1npm 12.2.1
resolved points at another tarball, integrity unchangedFails: EINTEGRITYFails: EINTEGRITY
resolved on another host (registry.yarnpkg.com), matching integrityInstalls left-pad as ms@2.1.3Fails: EALLOWREMOTE
resolved on registry.npmjs.org, another package, matching integrityInstalls left-pad as ms@2.1.3Installs left-pad as ms@2.1.3
resolved on registry.npmjs.org, older ms 2.0.0, matching integrityInstalls ms 2.0.0 as ms@2.1.3Installs ms 2.0.0 as ms@2.1.3
resolved swapped, integrity deletedInstalls left-pad, no checkInstalls left-pad, no check
Swapped to core-js 3.45.1 (has postinstall), with allowScripts: {"ms@2.1.3": true}Not testedInstalls core-js, blocks its script

npm 12 closed the foreign-host variant through a default aimed at URL dependencies. npm v12, 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 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: 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 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.

Shell Session
$ 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:

JavaScript
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)
Shell Session
$ 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:

Shell Session
$ 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 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.