Quick Answer

Dependency confusion happens when a package manager picks a public package over your private one because it reports a higher version number and nothing tells the resolver to prefer your private source instead. Alex Birsan proved this in 2021 by publishing fake packages under leaked internal names and getting code to execute inside 35+ companies. The fix is scoping your private packages (@yourorg/name) and pinning that scope to your private registry in .npmrc, so there is no public package for the resolver to ever compare against.

What Dependency Confusion Actually Is

Most build tools resolve package names the same way regardless of whether the package is meant to stay inside your company. If your team has an internal package called acme-payment-utils published only to a private feed, nothing on the package manager side marks that name as reserved. The name itself is just a string, and any public registry — npm, PyPI, RubyGems — will happily let a stranger register a package under that exact name.

Dependency confusion is what happens when that stranger's public package gets installed instead of your private one, during a normal build, with no one clicking a malicious link or approving anything. The attacker does not need your credentials or network access. They only need your internal package's name, which is often visible in a public package.json, a leaked build log, or a job posting that mentions your tech stack.

Once their package installs, its postinstall script runs automatically as part of npm install, with no further action required from anyone on your team.

How the Version Race Works

Package managers that check more than one source for a given name generally prefer the highest semantic version, not the source it came from. If your internal registry serves acme-payment-utils@1.4.0 and an attacker publishes acme-payment-utils@9.9.9 to the public npm registry, a resolver that queries both sources, or a private proxy configured to check upstream for updates, picks the higher number. It has no way to know that "higher" here means "malicious."

This is not a bug in semantic versioning. It is what semantic versioning is designed to do: assume a higher version is newer and preferred. The vulnerability is entirely in the trust model — nothing tells the resolver that acme-payment-utils should only ever come from your private registry, so it treats the public copy as a legitimate candidate instead of an impostor.

The same mechanism affects pip and RubyGems, which is why Alex Birsan's original research targeted all three ecosystems, not just npm.

The Research That Named It

Security researcher Alex Birsan formalized this attack in February 2021. He searched public GitHub repositories, leaked package.json and requirements.txt files, and even error messages from internal build tools to collect the names of private packages used inside large companies.

For each name he found, he published a same-named package to the matching public registry with an inflated version number and a preinstall or postinstall script that phoned home with the hostname, username, and local path of whatever machine ran the install — enough to prove code execution, without doing anything destructive.

The result: successful code execution inside more than 35 companies, including Apple, Microsoft, PayPal, Tesla, and Uber, with zero social engineering involved. Birsan collected over $130,000 in bug bounties across the disclosures. No one on those engineering teams did anything obviously wrong; they just depended on an internal package name that a public registry treated as up for grabs, which is exactly why the fix has to be structural rather than a matter of individual developer caution.

Real Fixes: Scoping and Registry Pinning

The most reliable fix is to stop giving internal packages plain, unscoped names. npm scopes (@yourorg/package-name) are reserved per account or organization — once you own @yourorg, nobody else can publish under it, on any registry.

Pair the scope with an explicit registry mapping in .npmrc:

@yourorg:registry=https://npm.yourorg.internal/
//npm.yourorg.internal/:_authToken=${NPM_TOKEN}

This is a hard mapping, not a preference. npm sends every request for an @yourorg/* package to that one registry and never asks the public registry at all — there is no version comparison for an attacker to win, because the public registry is never even consulted for that scope.

Combine this with committing your lockfile and running npm ci, not npm install, in CI. npm ci installs exactly what the lockfile records and fails loudly if package.json and the lockfile disagree, instead of silently re-resolving to a newer version someone just published.

The Gotcha: Your Private Registry Might Still Forward Requests

Teams that already run a private registry often assume they are covered, and that assumption is the actual gotcha. Tools like Verdaccio, Artifactory, and Nexus are commonly set up as a proxy: if a requested package is not found locally, the proxy transparently forwards the request upstream to the public registry and caches whatever comes back.

That default "uplink" behavior recreates the exact vulnerability a private registry is supposed to remove. A request for an internal-only package that has no public counterpart still gets forwarded upstream. If someone has since published a public package under that same name, the proxy fetches it, because from the proxy's point of view it is just satisfying a request it could not answer locally.

The fix is to check your proxy's uplink configuration for internal-only scopes and explicitly disable fallback to the public registry for them, rather than assuming "we have a private registry" is the same thing as "our private packages are protected."

Frequently Asked Questions

What is a dependency confusion attack? It is when a public package manager installs an attacker's package instead of your company's private one, because the attacker published the same name publicly with a higher version number and nothing told the resolver to prefer your private source.
Who discovered dependency confusion? Security researcher Alex Birsan formalized and named it in February 2021, demonstrating working code execution inside more than 35 companies including Apple, Microsoft, PayPal, Tesla, and Uber.
Does having a private npm registry alone stop this? Not by itself. Many private registries are configured as proxies that forward unmatched requests to the public registry by default, which recreates the same vulnerability unless that fallback is disabled for internal-only scopes.
How do scoped packages prevent dependency confusion? A scope like @yourorg is reserved to your account, and mapping it to your private registry in .npmrc means npm never queries the public registry for that scope at all, so there is no version race to lose.
Is dependency confusion only an npm problem? No. Birsan's original research demonstrated the same attack against pip and RubyGems, since the underlying issue, resolvers trusting whichever source reports the highest version, is not specific to any one package manager.