Ecosystem and productionThe software supply chainPart 1
Reading a Lockfile: Supply Chain in Practice
What package-lock.json actually records, what its integrity hashes do and do not prove, and where the real install-time risk sits.

Most developers have never read their lockfile. It is large, machine-generated, and merges badly, so it gets treated as an artifact rather than as a document.
It is worth ten minutes, because it is the only place your project states what code it actually runs — and because most advice about “supply chain security” is advice about this file, given without explaining what it contains.
Checked with npm 12.0.2 on Node 24.18.0, against npm 11.19.0 where the two disagree.
What is in there
Open package-lock.json and look past the top-level metadata at the packages
object. Each entry describes one installed thing:
{
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
}
}Four facts, and it is worth being precise about each.
version is the resolved version — what you got, not what package.json
asked for. ^1.1.0 in your manifest and 1.1.1 here is the normal state.
resolved is the URL the tarball came from. Anything that is not your expected
registry is worth a second look; this is where a misconfigured private registry
or a stale internal mirror shows up.
integrity is a Subresource Integrity string: a base64 SHA-512 of the tarball
bytes. On every subsequent install, npm hashes what it downloaded and compares.
A mismatch aborts the install.
The key itself — node_modules/picocolors — is the install path, which is how
the lockfile records nested duplicates when two dependents need incompatible
versions.
The nesting also answers “how much code is this really”. npm ls --all prints
the full tree; a mid-sized web application with twenty direct dependencies
routinely lands in four figures.
What the hash proves, and what it does not
The integrity hash proves exactly one thing: the tarball you are installing today is byte-identical to the one that was there when the lockfile was written. That is a real guarantee, and it defeats a whole class of attack — registry tampering, a mirror serving different bytes, a network-level swap.
It does not prove that the tarball corresponds to the repository it claims to come from. What gets published to npm is whatever the publisher uploaded. It can contain files the repository does not have, built from source that was never committed, and the hash will be perfectly valid. It has happened, more than once, in packages with large download counts.
Provenance is
what closes that gap. A package published from CI with npm publish --provenance carries a signed attestation, generated through Sigstore, linking
the tarball to the repository, the commit, and the workflow that built it. npmjs.com shows a provenance badge for those packages, and you
can verify locally:
npm audit signaturesThat checks registry signatures and provenance attestations for your installed tree. Coverage is not universal — plenty of widely used packages still publish from a laptop — but the trend is the right one, and the command is fast enough to sit in CI.
The part that actually runs code
Here is the thing the hashes do not address at all.
preinstall, install and postinstall scripts are arbitrary code, running as
your user with your environment — including whatever tokens are in it — before
any of your own code runs, before your tests, before any review. A dependency
six levels down the tree has the same access as one you chose.
The scripts are legitimate technology: native modules compile, and packages like
esbuild and fsevents place a platform-specific binary. The toolchain behind
a Vite dev server is several
such packages deep. But it means an install is not a download. It is an
execution.
Which is why npm 12 changed the default, and why the advice most of us learned
is now the wrong advice. From npm help install-scripts: “Dependency install
scripts are blocked by default. Install commands silently skip lifecycle scripts
for any dependency that does not have a matching entry in allowScripts, and end
with a list of the packages whose scripts were skipped.”
The difference is easy to see. A local dependency whose postinstall writes a
file, installed twice — once with npm 11.19.0, once with npm 12.0.2. Under 11
the file appears and npm warns. Under 12 the file does not appear:
npm warn install-scripts 1 package had install scripts blocked because they are not covered by allowScripts:
npm warn install-scripts [email protected] (postinstall: node -e "…")
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.So npm ci --ignore-scripts is no longer the lever it was. It still turns off
your own project’s lifecycle scripts, but for dependencies it now only subtracts
from a list you wrote deliberately. That list is allowScripts in
package.json, maintained with npm install-scripts approve <pkg> and reviewed
with npm install-scripts ls. The package.json of the site you are reading
carries two entries, [email protected] and [email protected], pinned to those
versions, and nothing else runs code at install time.
pnpm arrived at the same shape from the other direction: it has blocked
dependency scripts by default since v10, and pnpm 11 replaced
onlyBuiltDependencies with allowBuilds, a map from package patterns to
booleans. Two package managers, one conclusion.
npm 11 is the halfway house, and worth recognizing if that is what your CI
pins. It already reads allowScripts and already prints the warning — the
wording is not yet covered by allowScripts rather than blocked — but it runs
the scripts anyway. On npm 11 the warning is the only thing you get, and
--ignore-scripts is still what sits between you and a stranger’s
postinstall. Either way the useful question is unchanged: which packages in my
tree run code at install time, and do I know why?
npm ci versus npm install
The distinction matters more than it looks.
npm install reconciles package.json with the lockfile and may rewrite the
lockfile — adding a package, updating a range, changing a resolution. That is
correct behavior for development.
npm ci deletes node_modules, installs exactly what the lockfile says, and
fails if the lockfile and package.json disagree. It never writes the
lockfile.
In CI, only the second is defensible. A pipeline running npm install can
silently install a version that nobody has ever tested, because a transitive
dependency published a patch release between your last commit and this build.
It belongs next to the other checks a build has to make on its own account,
type checking among them.
Two related habits:
- Commit the lockfile. Every project, including libraries. The published package ignores it; your CI does not.
- Use
overridesinpackage.jsonto pin a transitive dependency you have a reason to distrust. It is the only supported way to fix a version you do not depend on directly.
What the lockfile cannot do for you
A lockfile freezes a resolution. It does not evaluate it. If the version you locked was already compromised, the hash faithfully guarantees you the compromised bytes, forever, with a green checkmark.
The gap it leaves is time. The dangerous window for a compromised release is
the first hours after publication, before anyone notices — and an automated
dependency bot that upgrades within minutes runs straight into it. Your package
manager probably has a setting for this and probably has it switched off: npm’s
min-release-age refuses anything younger than the age you give it, with
min-release-age-exclude for packages you need the moment they ship, and it is
null until you set it. pnpm calls it minimumReleaseAge. Where the tooling
offers nothing, delaying non-security upgrades by a few days costs nothing and
removes most of the exposure.
Reading the lockfile is where all of this starts, because it is the only complete list of what you are trusting. A dependency you cannot justify is a dependency you can remove, and removal is the only mitigation that works completely.
FAQ
Frequently asked
npm 11 says install scripts are blocked by default. Are they?
Not on npm 11.19.0, and that version ships two manual pages that disagree about it. `npm help install-scripts` reads "Dependency install scripts are blocked by default", while `npm help approve-scripts` in the same package reads "In the current release, this field is advisory: install scripts still run by default". Measured on 11.19.0, the second page is the accurate one: a dependency whose postinstall writes a file writes it, and npm prints a warning instead of skipping anything. npm 12.0.0 is the release that actually changed the default, on 2026-07-08, and npm@latest has been 12.x since. So the answer depends on what your CI pins, and on npm 11 --ignore-scripts is still what sits between you and a stranger's postinstall.
Does a matching integrity hash mean the package is safe?
No. It proves the tarball you are installing is byte-identical to the one that was there when the lockfile was written, which defeats registry tampering and mirror swaps and nothing else. It says nothing about whether those bytes match the repository the package claims to come from, and if the version you locked was already compromised the hash guarantees you the compromised bytes with a green checkmark. Provenance attestations are what address the repository question, and npm audit signatures is what checks them.


