Safeguard
Supply Chain Attacks

Anatomy of an npm Dependency Confusion Attack

One researcher published fake packages matching internal names at over 35 companies in 2021 and collected six-figure bounties — here's exactly how the registry resolution flaw works.

Safeguard Research Team
Research
Updated 6 min read

To understand this attack you first need the basic definition: what is a dependency in programming? It's any external package or library your own code calls into rather than reimplements, resolved and fetched by a package manager (npm, pip, Maven, and so on) based on a name and version range you declare — and it's exactly that resolution step one researcher found a way to hijack. On February 9, 2021, researcher Alex Birsan published "Dependency Confusion: How I Hacked Into Apple, Microsoft and Dozens of Other Companies" — a Medium post that turned a quiet quirk of package manager resolution into one of the most consequential supply chain findings of the decade. Birsan's method was simple: he scraped internal, private package names out of leaked package.json files, internal GitHub repos, and even error messages posted to public forums, then published public npm packages using those exact names with artificially high version numbers. When the affected companies' build systems next ran npm install, many pulled his public package instead of the internal one — because npm, by default, checks the public registry and will happily resolve to whichever configured source offers the highest semver version. His packages carried a benign "phone-home" script that beaconed the executing hostname, username, and file path back to a server he controlled, proving code execution inside networks belonging to PayPal, Microsoft, Apple, Netflix, Yelp, Tesla, Uber, and Shopify. He was paid substantial bug bounties across the affected organizations for responsibly disclosing the flaw. Five years on, the underlying registry behavior he exploited is still the default in most package manager configurations — which is why dependency confusion remains a live threat class rather than a historical curiosity.

What exactly did Birsan exploit?

To understand the attack, it helps to be precise about the mechanics behind that dependency definition: package managers don't just fetch a name, they resolve it against whichever registries are configured, and Birsan exploited an ambiguity in how those package managers decide which registry wins when a dependency's name exists in more than one place. Many companies in 2021 ran internal packages through a private registry or feed — often layered on top of Artifactory or Azure Artifacts — while still allowing the public npm registry as a fallback or parallel source for everything else. Neither npm nor the private feed configuration in most default setups enforced that an internal package name could only ever come from the internal source. Instead, resolution logic frequently followed simple version comparison: if the public registry offered version 9999.0.0 of a package called some-internal-lib and the private feed offered 1.2.0, the resolver picked the public one because it was semantically "newer." Birsan didn't need to guess correctly on the first try either — he mined internal names from package.json "dependencies" blocks accidentally committed to public repos, from JavaScript source maps shipped to production, and from employees' own forum posts referencing internal tool names, then simply registered all of them on the public registry as insurance.

Why does registry precedence break this way by default?

Registry precedence breaks this way because most package managers were designed around a single logical namespace with configurable sources, not a namespace with a hard security boundary between "internal" and "public." When npm resolves some-internal-lib, its default behavior — absent explicit scope configuration — is to query the registries listed in .npmrc and apply standard semver resolution, not to ask "which of these registries is authoritative for this name." Microsoft acknowledged this directly in 2021, changing Azure Artifacts' default upstream-source behavior so a feed only falls back to an external registry when no internally sourced version of that package already exists, rather than resolving to whichever source answers with the highest version. Python's pip and Ruby's RubyGems had (and largely still have) the same architectural gap in their own resolution logic, which is why Birsan's original research demonstrated variants of the attack across all three ecosystems rather than npm alone — the flaw was never npm-specific, it was a shared assumption in how multi-source package resolution was built.

Does a lockfile protect you from this?

A lockfile helps far less than most teams assume, because the vulnerable moment is usually the first resolution, not a subsequent one. A package-lock.json or yarn.lock pins versions for a project that has already resolved its dependency graph once — but a fresh clone, a CI runner with a cold cache, a npm install after deleting node_modules, or a lockfile regeneration during a dependency bump all trigger new resolution against whatever registries are configured. If an attacker's package was published after your lockfile was generated but your CI cache gets busted or a developer runs npm update, the resolver goes back to the registries and can pick up the malicious public package for the first time. Lockfiles are a real defense against version drift, but they don't substitute for actually telling your tooling which registry is authoritative for your internal scope — that's a configuration problem, not a lockfile problem.

What actually stops dependency confusion?

Scoped packages are the single most effective fix, because npm scopes (@yourcompany/package-name) cannot be silently claimed by an arbitrary public-registry user the way an unscoped name like internal-utils can. Pairing scopes with explicit registry pinning in .npmrc closes the rest of the gap: a line like @yourcompany:registry=https://your-internal-registry.example.com combined with always-auth=true tells npm that any package under that scope must come from the internal feed, full stop — there's no fallback path to the public registry for that namespace at all. Azure Artifacts' upstream source feature and equivalent controls in Artifactory and Sonatype Nexus enforce the same precedence at the registry-server level, so misconfigured client machines don't reopen the hole. Teams that skip scoping but still publish placeholder packages under their internal names on the public registry ("registering the name to block it") get partial protection, but scoping plus pinned precedence is the durable fix — it removes the ambiguity Birsan's research exploited rather than just occupying the name.

How does Safeguard help with this?

Safeguard's Package Firewall runs as an install-time proxy in front of npm and pip, so every fetch — including transitive dependencies pulled in by your direct dependencies — is evaluated before code reaches disk. It specifically checks for dependency and namespace confusion: names that could resolve to a public package in place of an intended internal one, or that squat on an internal namespace, are flagged and can be set to allow, warn, block, or quarantine depending on your policy. Because coverage is transitive by construction rather than a separate lockfile-parsing step, a confusion attempt introduced three layers deep in your dependency tree is caught the same way a direct one would be. Quarantined packages are re-evaluated automatically and auto-released if a verdict clears, with a 14-day default aging window, and every decision is written to an audit trail — giving security teams the same visibility into "what almost got installed" that Birsan's research showed most companies were missing entirely in 2021.

Never miss an update

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