Mert Özen Further With Every Line
Backend Node.js Fundamentals — #04

npm in Depth: Dependencies, SemVer Ranges, package-lock, npm ci, Workspaces and Supply Chain Security

Mert Özen Nov 14, 2026 12 min 9 views
npm in Depth: Dependencies, SemVer Ranges, package-lock, npm ci, Workspaces and Supply Chain Security

npm pins the range in package.json to the exact version it records in package-lock.json: as long as the lock satisfies the range npm install does not move to a newer version, and npm ci allows no deviation from it. Real output shows what ^ and ~ select among Express versions, the 0.x caret trap, npm ci stopping on a mismatched lock, nested content-type copies, the @bookstore/contracts workspace, npm audit, install script approval with strict-allow-scripts, and signature verification.

Version note: The examples in this post were run with Node.js 26.13.1 (LTS) and npm 11.20.0; the sample dependency is express 5.2.1 and 5.3.0.

Guess First

We have a lab folder for trying out npm for Bookstore, the online bookstore used as the shared example across this site's series. Its package.json declares Express like this:

"dependencies": {
  "express": "^5.2.1"
}

package-lock.json recorded 5.2.1, installed in December 2025. Since then 5.3.0 has reached the registry, and the ^5.2.1 range covers it. A teammate clones the repo fresh and runs npm install. Which version gets installed? Would the result change if someone else did the same with npm ci?

npm install
npm ls express
added 69 packages, and audited 70 packages in 431ms

found 0 vulnerabilities
npm-lab@1.0.0 /home/user/bookstore-api/labs/04
`-- express@5.2.1

The installed version is 5.2.1, and npm ci installs the same. As long as the lock file satisfies the range, npm install does not change it. To see the new version you have to ask separately:

npm outdated
Package  Current  Wanted  Latest  Location              Depended by
express    5.2.1   5.3.0   5.3.0  node_modules/express  04
semver     7.7.3   7.8.6   7.8.6  node_modules/semver   04

"Wanted" is the newest version the range allows; "Current" is the one in the lock. Upgrading is a deliberate step: npm update express moves the lock file to 5.3.0 and leaves the ^5.2.1 in package.json untouched. This post covers the rules behind that behaviour, how they differ from NuGet, how node_modules is laid out, the npm workspace we add to the Bookstore API, and the measures to take against the dependency chain.

dependencies, devDependencies, peer and optional: How They Differ from NuGet

In .NET, dependencies are the PackageReference lines in a .csproj; when you write a version NuGet reads it as "at least this version" and picks the lowest matching version in the graph. npm's default is close to the opposite: you write a range, npm picks the newest version in the range and then records it in the lock file. package.json has four kinds of dependencies:

FieldWhenExample from Bookstore
dependenciesPackages needed while the application runsExpress, mssql, pino (in later posts)
devDependenciesOnly needed for development, tests and buildsTypeScript, @types/node, semver
peerDependenciesA package that the consuming application must install, not the package itselfAn Express plugin needing Express
optionalDependenciesPackages that do not fail the install if they cannot be installed, mostly platform-specific binariesTypeScript 7's native packages chosen by operating system

Since npm 7, peer dependencies are installed automatically by default, and a conflict stops the install. The --legacy-peer-deps flag you see used to get around it does not solve the problem, it only hides it. The devDependencies split matters for production; npm ci --omit=dev does not install them at all:

npm ci --omit=dev
added 68 packages, and audited 69 packages in 516ms

found 0 vulnerabilities

In the same folder a plain npm ci installs 69 packages; the only difference is semver, a devDependency.

SemVer Ranges: ^, ~ and the 0.x Trap

npm packages use SemVer. Rather than memorising ranges, let's try them against the real Express versions in the registry. The script uses the semver package, the same library npm itself uses:

// Which published versions does each range accept? Uses the real version list from the registry
import semver from "semver";

const res = await fetch("https://registry.npmjs.org/express");
const { versions } = await res.json();
const stable = Object.keys(versions).filter((v) => !semver.prerelease(v) && semver.gte(v, "4.21.0"));

for (const range of ["5.2.1", "~5.2.0", "^5.2.0", "^4.21.0", ">=5.0.0 <5.2.0"]) {
    const matches = stable.filter((v) => semver.satisfies(v, range));
    console.log(range.padEnd(16), "→", matches.join(", ") || "(none)");
}

// Caret on 0.x: only the patch may change
console.log("^0.7.1 accepts 0.7.2:", semver.satisfies("0.7.2", "^0.7.1"));
console.log("^0.7.1 accepts 0.8.0:", semver.satisfies("0.8.0", "^0.7.1"));
node labs/04/ranges.js
5.2.1            → 5.2.1
~5.2.0           → 5.2.0, 5.2.1
^5.2.0           → 5.2.0, 5.2.1, 5.3.0
^4.21.0          → 4.21.0, 4.21.1, 4.21.2, 4.22.0, 4.22.1, 4.22.2, 4.22.3
>=5.0.0 <5.2.0   → 5.0.0, 5.0.1, 5.1.0
^0.7.1 accepts 0.7.2: true
^0.7.1 accepts 0.8.0: false

A caret (^) pins the major number and leaves minor and patch free; a tilde (~) leaves only the patch free. npm install express writes a caret by default. The last two lines are the rule people often miss: while the major is zero, the caret pins the minor too, because under SemVer any 0.x minor release may contain a breaking change. Express 5.3.0's own dependencies include a line like cookie: '^0.7.1', which means cookie will not move to 0.8.

package-lock.json and npm ci

Ranges allow the same package.json to produce different trees on different days. package-lock.json stops that: it records the exact version of every installed package, where it was downloaded from and the hash of its contents (integrity). In NuGet packages.lock.json does the same job, but there it is opt-in; npm produces it by default and it must be committed.

npm install reads the lock, installs from it if it matches package.json, and otherwise resolves the tree again and updates the lock. npm ci never tries to change the lock: it deletes node_modules, installs exactly the tree in the lock, and stops if there is a mismatch. That is the right command for CI and image builds. To see a mismatch, in a copy of the lab I changed the range in package.json to ^5.3.0 by hand and ran npm ci without updating the lock (start of the output):

npm error code EUSAGE
npm error
npm error `npm ci` can only install packages when your package.json and package-lock.json or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install` before continuing.
npm error
npm error Invalid: lock file's express@5.2.1 does not satisfy express@5.3.0
npm error Invalid: lock file's content-disposition@1.1.0 does not satisfy content-disposition@2.0.1
npm error Invalid: lock file's content-type@1.0.5 does not satisfy content-type@2.1.0

A good outcome: CI stopped instead of silently doing a lock update the developer forgot. When you get a merge conflict in the lock file, do not delete and regenerate it either; resolve the conflict or let npm install fix the lock according to package.json. Deleting it moves the whole tree to the newest versions of the day.

How Is node_modules Laid Out? Hoisting, npm ls and npm explain

Express brings 28 direct dependencies, and they bring others; the lab installed 69 packages. npm places them at the top level of node_modules whenever it can (hoisting), because when Node looks for a package it first checks the node_modules in its own folder, then the one above, walking up. If two incompatible versions of the same package are needed, one sits at the top and the other under the package that asked for it. That is exactly what happened in the lab:

npm ls content-type
npm-lab@1.0.0 /home/user/bookstore-api/labs/04
`-- express@5.2.1
  +-- accepts@2.0.0
  | `-- negotiator@1.1.0
  |   `-- content-type@2.1.0
  +-- body-parser@2.4.0
  | `-- content-type@2.1.0
  +-- content-type@1.0.5
  `-- type-is@2.1.0
    `-- content-type@2.1.0

Express 5.2.1 wants the 1.x line of content-type, and 1.0.5 sits at the top; body-parser, negotiator and type-is want 2.x and each keeps 2.1.0 in its own node_modules. On disk there really are three nested node_modules folders. The answer to "why is this package installed?" is npm explain:

npm explain ms
ms@2.1.3
node_modules/ms
  ms@"^2.1.3" from debug@4.4.3
  node_modules/debug
    debug@"^4.4.3" from body-parser@2.4.0
    node_modules/body-parser
      body-parser@"^2.2.1" from express@5.2.1
      node_modules/express
        express@"^5.2.1" from the root project

Only the first chain of the output is shown here; the full output lists eight separate paths from ms up to Express. This is the first command to run when a security advisory names a package you never installed yourself.

Hoisting has a side effect: you can import a package that made it to the top level from your code even though it is not in your package.json. This is called a phantom dependency, and your code breaks the day Express drops that package. pnpm takes a different route for this reason: it keeps packages in a shared store and links them into node_modules with symlinks, so you can only reach the packages you declared. This series stays with npm; we apply the same rule through discipline.

npm Workspaces: @bookstore/contracts for Bookstore

In Bookstore, rules such as order statuses, the ISBN check and the book summary the API returns are needed not only in the API but also in the future BFF and in tool scripts. In .NET you would add a class library project to the solution and give it a ProjectReference. The npm counterpart is a workspace: defining a folder in the same repo as a separate package and linking it to the root project. I added two things to the root package.json:

"workspaces": [
  "packages/*"
],
"dependencies": {
  "@bookstore/contracts": "^0.1.0"
}
{
  "name": "@bookstore/contracts",
  "version": "0.1.0",
  "private": true,
  "type": "module",
  "exports": {
    ".": "./src/index.js"
  }
}
// Shapes and rules shared by the API and other Bookstore Node.js packages
export const ORDER_STATUSES = ["Pending", "Paid", "Shipped", "Delivered", "Cancelled"];

export function isValidIsbn13(isbn) {
    if (!/^97[89]\d{10}$/.test(isbn)) {
        return false;
    }
    const sum = [...isbn].reduce((acc, ch, i) => acc + Number(ch) * (i % 2 === 0 ? 1 : 3), 0);
    return sum % 10 === 0;
}

export function toBookSummary(book) {
    return {
        isbn: book.isbn,
        title: book.title,
        price: { amount: book.price, currency: book.currency },
    };
}

When you run npm install at the root, npm links node_modules/@bookstore/contracts to packages/contracts as a symlink and writes "link": true into the lock file:

npm ls --all
bookstore-api@0.1.0 /home/user/bookstore-api
`-- @bookstore/contracts@0.1.0 -> ./packages/contracts

The API now uses the package by name; a change in packages/contracts shows up on the next run, with no publishing or reinstalling:

import { toBookSummary } from "@bookstore/contracts";
import { listBooks } from "#services/bookService";

// GET /api/books
export function handleListBooks(req, res) {
    res.writeHead(200, { "content-type": "application/json" });
    res.end(JSON.stringify(listBooks().map(toBookSummary)));
}
[{"isbn":"9780441172719","title":"Dune","price":{"amount":349.9,"currency":"TRY"}},{"isbn":"9780547928227","title":"The Hobbit","price":{"amount":289.5,"currency":"TRY"}},{"isbn":"9780141439600","title":"Middlemarch","price":{"amount":310,"currency":"TRY"}},{"isbn":"9780141439587","title":"Emma","price":{"amount":245,"currency":"TRY"}}]

To run commands in a specific workspace use -w (npm run build -w @bookstore/contracts); to run them in all of them use --workspaces. The package's exports field works as in the previous post: the API can only see what src/index.js exposes.

Supply Chain Security: Audit, Install Scripts and Signatures

Each of the 69 packages can run code on your machine and in production if its author's account is taken over or a malicious version is published. Let's look at npm's tools against that risk; the detailed threat model and the scanners in CI belong to the DevSecOps series.

npm audit

npm audit compares the versions in the lock file with advisories in the GitHub Advisory Database. It works without installing packages; in an empty scratch folder I produced only a lock file for the old Express 4.17.1 (npm install express@4.17.1 --package-lock-only) and ran the audit. The output is shortened:

# npm audit report

body-parser  <=1.20.8
Severity: high
body-parser vulnerable to denial of service when url encoding is enabled - https://github.com/advisories/GHSA-qwcr-r2fm-qrc7
...
path-to-regexp  <=0.1.12
Severity: high
path-to-regexp outputs backtracking regular expressions - https://github.com/advisories/GHSA-9wv6-86v2-598j
...
7 vulnerabilities (3 low, 4 high)

In CI, npm audit --omit=dev --audit-level=high returns a non-zero exit code when there is a high-severity finding; in this example it returned 1. npm audit fix installs the fixes the ranges allow; --force may do major upgrades, so be careful with it.

Install scripts and allowScripts

The biggest risk is scripts that run at install time: a package's preinstall, install or postinstall script runs with your permissions during npm install and sees all your environment variables. I tried it with a lab package; npm 11.20.0 ran the script and warned at the end:

isbn-tools postinstall ran with access to 106 env vars
...
npm warn install-scripts 1 package has install scripts not yet covered by allowScripts:
npm warn install-scripts   isbn-tools@1.0.0 (postinstall: node -e "console.log('isbn-tools postinstall ran with access to', Object.keys(process.env).length, 'env vars')")
npm warn install-scripts
npm warn install-scripts Run `npm install-scripts ls` to review, or `npm install-scripts approve <pkg>` to allow.

In this version the default is to warn. If you put strict-allow-scripts=true in the project's .npmrc, a package with an unapproved script stops the install. In an empty scratch folder, with esbuild, which has a postinstall script:

npm error code ESTRICTALLOWSCRIPTS
npm error --strict-allow-scripts: 1 package(s) have install scripts not covered by allowScripts:
npm error   esbuild@0.28.2 (postinstall: node install.js)
npm error Approve them with `npm install-scripts approve`, deny them with `npm install-scripts deny`, or bypass this check with `--dangerously-allow-all-scripts`.

The flow is: install the package without running scripts (npm install esbuild --ignore-scripts), see the list with npm install-scripts ls, and approve after reviewing the script:

npm install-scripts approve esbuild
Approved esbuild:
  added esbuild@0.28.2

The approval is written to package.json as "allowScripts": { "esbuild@0.28.2": true }. Because it is pinned to the version, you have to review the script again when esbuild is upgraded; after this, npm ci ran without trouble. I added the same setting to the Bookstore API's .npmrc:

engine-strict=true
strict-allow-scripts=true

Signatures, provenance and choosing packages

npm audit signatures
audited 69 packages in 1s

69 packages have verified registry signatures

5 packages have verified attestations

A registry signature verifies that the package came from the npm registry unchanged. An attestation (provenance) is a stronger claim: it records, signed through Sigstore, which public repo and which CI run built the package. Only 5 of 69 packages having one shows where the ecosystem is today. When you add a new package, make these checks a habit:

  • Check the name letter by letter; typosquatting packages such as expresss or express-js are published under names close to the real one.
  • Use npm view <package> to see the publisher, the latest publish date, weekly downloads and the repo link.
  • If the package has an install script, read what it does; if it did not have one and one appeared later, that is a red flag.
  • Do not add a dependency for a one-line helper; write the code yourself.
  • Do not jump to freshly published versions; npm's min-release-age setting refuses to install versions newer than a given number of days.

In Production

Use npm ci --omit=dev in the image build. The TypeScript compiler, test tools and types should not end up in the production image; both the image size and the attack surface grow. The Docker series covers the multi-stage Dockerfile for projects that need a build.

In CI run npm ci first, then npm audit signatures and npm audit --audit-level=high. Review pull requests that change the lock file separately: a package nobody asked for may have entered the tree.

Update dependencies regularly and in small steps. Weekly pull requests opened by a tool such as Dependabot or Renovate are far easier to review than a big upgrade every six months.

Use a scope (@bookstore) for private packages and claim that scope on the public registry as well; otherwise someone can publish a public package with the same name and get into your install.

Common Pitfalls

Deleting the lock file and hoping the problem goes away. Deleting the lock moves the whole tree to the newest versions of the day; while fixing one problem it can bring ten new ones.

Carrying devDependencies into the production image. Building the image with npm install installs devDependencies too; write npm ci --omit=dev.

Importing a package that is not in package.json. Thanks to hoisting it works today, and it breaks when a dependency's dependency changes.

Silencing a peer dependency error with --legacy-peer-deps. The conflict does not go away; it comes back as odd runtime errors.

Running npm install in CI. It can change the lock file silently and produce different trees between builds.

A Small Experiment

★☆☆ Add the ranges "5.x", "*" and ">=4.22.0 || ^5.0.0" to labs/04/ranges.js. Predict the results before you run it.

★★☆ Add a formatPrice function to packages/contracts and, without changing exports, try importing it from the path @bookstore/contracts/src/index.js. Which error do you get? Then define a separate subpath export called "./pricing".

★★★ In the lab folder, write a small script that finds the nested copies in node_modules from the output of npm ls --all --parseable: how many different versions of which package are installed?

The dependencies are in place; next come types. In the next post we convert the Bookstore API to TypeScript, without a build step: Node 26's type stripping, erasableSyntaxOnly, running the type check as a separate step, and the limits of this approach.