Safeguard
Supply Chain

What package-lock.json Does and Why You Should Commit It

The main package-lock.json use is pinning your entire npm dependency tree to exact, integrity-checked versions so every install is reproducible. Here is what is inside it and why deleting it is a bad habit.

Safeguard Research Team
Research
6 min read

The primary package-lock.json use is to freeze your entire npm dependency tree — every direct and transitive package, its exact version, where it was downloaded from, and a cryptographic hash of its contents — so that every install on every machine produces the same result. If you have ever wondered what is package-lock.json actually for, the short answer is reproducibility with an integrity guarantee. package.json describes what you want; package-lock.json records what you actually got. That distinction matters more for security than most teams realize.

What package.json Cannot Do on Its Own

A typical dependency entry looks like this:

{
  "dependencies": {
    "express": "^4.18.2"
  }
}

The caret means "any 4.x version at or above 4.18.2." So two developers running npm install a week apart can end up with different versions of express — and wildly different versions of the hundreds of transitive packages underneath it. A project with 20 direct dependencies routinely resolves to 800 or more packages, and semver ranges exist at every level of that tree.

Without a lockfile, your build is a moving target. A transitive maintainer publishes a bad patch release on Tuesday, and your Wednesday deploy ships it, even though nobody on your team changed a line of code.

What Is Inside the Lockfile

Open package-lock.json and you will find entries like this (lockfile version 3, the default since npm 9):

"node_modules/express": {
  "version": "4.18.2",
  "resolved": "https://registry.npmjs.org/express/-/express-4.18.2.tgz",
  "integrity": "sha512-5/PsL6iGPdfQ/lKM1UuielYgv3BUoJfz1aUwU9vHZ+J7gyvwdQXFEBIEIaxeGf0GIcreATNyBExtalisDbuMqQ==",
  "dependencies": { "accepts": "~1.3.8", "..." : "..." }
}

Three fields do the heavy lifting:

  • version pins the exact release, killing semver drift.
  • resolved records the registry URL the tarball came from, which surfaces surprises like a dependency suddenly resolving from an unexpected registry.
  • integrity is a Subresource Integrity hash (sha512). If the tarball npm downloads tomorrow does not hash to this value, the install fails.

That integrity field is the quiet security win. If a package version is republished with different contents, or a mirror serves a tampered tarball, npm refuses to install it. The lockfile turns "trust the registry" into "verify against what we reviewed."

npm install vs. npm ci

Two commands consume the lockfile very differently, and mixing them up is the most common source of lockfile drift:

# May update package-lock.json to satisfy package.json ranges
npm install

# Installs exactly what the lockfile says, or fails loudly
npm ci

npm ci deletes node_modules, installs precisely the locked tree, and errors out if package.json and package-lock.json disagree. It is the only correct choice for CI pipelines and production image builds. If your Dockerfile says npm install, your "reproducible" build is quietly allowed to re-resolve dependencies at build time.

A sane baseline:

# Local development, when intentionally changing deps
npm install some-package

# CI, Docker, production
npm ci --omit=dev

Why Deleting the Lockfile Is a Reflex Worth Unlearning

Every Node developer has seen the advice: "just delete package-lock.json and node_modules and reinstall." It sometimes clears a corrupted state, but understand what it actually does — it throws away every pin and re-resolves the entire tree to the newest versions your ranges allow. You have effectively upgraded hundreds of packages in one unreviewed commit.

The 2018 event-stream incident is the canonical cautionary tale: a malicious version of a transitive dependency was live on the registry for weeks. Projects that had locked to the earlier, clean version and installed with npm ci never pulled it. Projects that regenerated their lockfile during that window did. When peer dependency conflicts push you toward nuking the lockfile, there are narrower fixes — see our breakdown of npm's --legacy-peer-deps flag before reaching for deletion.

If you must regenerate, do it deliberately: regenerate on a branch, diff the result, and run your dependency scanner against the new tree before merging.

Lockfiles as an Attack Surface

Ironically, the file that protects you is also a target. A pull request that changes 4 lines of application code and 3,000 lines of package-lock.json deserves suspicion, because almost nobody reviews lockfile diffs by hand. Known abuse patterns include:

  • Resolved-URL swaps: an entry's resolved field pointed at an attacker-controlled registry mirror while the version number looks unchanged.
  • Integrity downgrades: replacing sha512 hashes to match a tampered tarball.
  • Ghost entries: adding a malicious package to the lockfile that no package.json entry references, which npm will still happily install.

Practical countermeasures: run lockfile-lint in CI to enforce that every resolved URL points at your expected registry over HTTPS, treat large lockfile diffs as review-blocking, and let automation do the reading. An SCA tool such as Safeguard's software composition analysis diffs the resolved dependency tree on every PR and flags new packages, registry changes, and known-vulnerable pins that a human reviewer would scroll past.

Answering the Perennial Objections

"It causes merge conflicts." Since lockfile v2, npm resolves most lockfile conflicts automatically: fix package.json, run npm install, and npm regenerates the conflicted sections. The occasional conflict is a far smaller cost than non-reproducible builds.

"We're building a library, not an app." Commit it anyway. Published tarballs do not include your lockfile (npm ignores it at publish time), so it never constrains your consumers — but it makes your own CI and contributor installs deterministic. This has been npm's own guidance for years.

"Dependabot keeps changing it." That is the system working. Automated bumps with a lockfile give you one reviewable, testable diff per upgrade instead of silent drift.

A Minimal Lockfile Hygiene Checklist

  1. Commit package-lock.json; never add it to .gitignore.
  2. Use npm ci everywhere automated: CI, Dockerfiles, release scripts.
  3. Enable lockfile-lint or equivalent to validate registry hosts and HTTPS.
  4. Review lockfile diffs on PRs, with tooling rather than eyeballs.
  5. Regenerate the lockfile only deliberately, on a branch, with a scan before merge.
  6. Keep npm versions consistent across the team (a .nvmrc or engines field helps) so lockfile versions do not churn.

None of this is exotic. It is the cheapest supply chain control you can deploy this week, and it costs one git add.

FAQ

What is package-lock.json in one sentence?

It is an auto-generated manifest that records the exact version, download URL, and integrity hash of every package in your dependency tree, so installs are reproducible and tamper-evident.

Should package-lock.json be committed to git?

Yes, for both applications and libraries. It guarantees identical installs across machines and CI, and npm excludes it from published tarballs, so it never affects downstream consumers of a library.

What is the difference between npm install and npm ci?

npm install may re-resolve versions and rewrite the lockfile to satisfy package.json ranges. npm ci installs exactly what the lockfile specifies and fails if the two files disagree, which is what you want in any automated environment.

Does a lockfile mean I am safe from malicious packages?

No — it means you will not silently pick up new malicious versions between deliberate updates. You still need to scan the pinned tree for known vulnerabilities and review what changes when the lockfile updates. Pinning a bad version faithfully reproduces a bad install.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.