Security concepts

What is a dependency confusion attack?

A dependency confusion attack (substitution attack) tricks the installer into taking a public package that shares an internal name when the public version is higher. Alex Birsan's February 2021 research showed how those names leak. CVE-2018-20225 is pip --extra-index-url. npm's 2023-24 mitigation is a reserved org scope plus a committed @scope:registry= binding.

10 min read
In short: A dependency confusion attack (substitution attack) tricks the installer into taking a public package that shares an internal name when the public version is higher. Alex Birsan's February 2021 research showed how those names leak. CVE-2018-20225 is pip --extra-index-url. npm's 2023-24 mitigation is a reserved org scope plus a committed @scope:registry= binding.

What is a dependency confusion attack?

A dependency confusion attack (also called a substitution attack) tricks a package manager into installing a public package that shares the name of an internal one, usually because the public copy has a higher version number. The installer is doing what it was designed to do: when the same name exists in more than one registry, pick the newest match. The attacker publishes first, or publishes newer.

A dependency is any external component your project needs to build or run. You pull it from a public registry (npm, PyPI) or a private one (GitHub Packages, Artifactory, a company Verdaccio). Because that code runs in your build and often in production, a name collision is a remote-code-execution path. Related supply-chain failures are covered in package hijacking and trivial packages.

The opening story on this topic is often NotPetya or a protest payload in node-ipc. Those are dependency attacks. They are not this attack. Dependency confusion is specifically the name-collision plus version-priority failure.

How do attackers execute a dependency confusion attack?

Private packages are not republished to the public index. That split is deliberate. It also means the same name can exist in both places.

Any installer that searches more than one index, then prefers the highest version, is exposed. The attacker:

  1. Learns an internal package name that is not reserved on the public registry.
  2. Publishes a package with that exact name and a version higher than yours (99.0.0 is the usual joke).
  3. Waits for a developer laptop or a CI job whose .npmrc (or pip extra index) consults the public registry for that name.
  4. Runs an install-time script, or ships a module that looks like yours and also exfiltrates process.env.

If the private copy is internal-auth@1.4.2 and the public copy is internal-auth@1.4.3, many resolvers take 1.4.3. Scoped names (@acme/internal-auth) close most of this door, but only if the scope is reserved on the public registry and bound to your private registry in every install environment. An unbound scope, a missing .npmrc in CI, or an unscoped leftover name is enough.

How do attackers find the names of private packages?

Private should mean hard to enumerate. In practice the names leak.

On 9 February 2021, Alex Birsan published a proof of concept that used ordinary open-source intelligence. He found internal names on GitHub, in public forums, inside JavaScript bundles, in package.json files, and in packages that had been published to the public registry by mistake. Several large companies installed his placeholder packages from CI. The names were already public; the installer did the rest.

Treat every internal name as enumerable. Search your own GitHub org, your docs, your job posts, and the Wayback Machine the way an attacker would. If a name is out, reserve it or rename it. Renaming alone is obscurity. Reservation is the control.

What does CVE-2018-20225 show about pip?

Python's pip accepts --extra-index-url. That flag tells pip to search both the extra index and the default public index (PyPI). If the package exists in both, pip prefers the higher version. The pip maintainers marked the report CLOSED WONTFIX: the behavior is intended, and callers are expected to understand the trust model.

Terminal window
pip install --extra-index-url https://pypi.internal.example.com internal-auth

That one-liner is the vulnerability. December 2022 made it concrete: a dependency-confusion package reused a name adjacent to the PyTorch extra index and ran on developer machines that had configured --extra-index-url. Prefer --index-url pointed only at a registry you control, or pin hashes with pip-tools / uv so a higher public version cannot win. Do not treat an extra index as a private-only search path.

What did npm change in 2023-24 for scoped registries?

npm's defense is a reserved scope plus a committed registry binding. GitHub's guidance on substitution attacks is the baseline: create the organization on the public registry so nobody else can publish @yourco/*, then point that scope at your private registry in .npmrc. A misconfigured build then 404s instead of silently fetching untrusted content.

Through 2023 and 2024 the surrounding controls got stricter:

  • Scoped registry binding is the supported fix. @yourco:registry= is a first-class npm config. One scope maps to one registry. Unscoped internal names do not get this protection.
  • Provenance attestations (generally available in 2023) let you verify that a public package was built from a named repository workflow, which does not stop confusion of your internal name but does raise the bar for impersonating a popular public package.
  • Granular access tokens and mandatory 2FA for publishing reduced the chance that a leaked classic token publishes under a scope you reserved.
  • Lockfile v3 / npm 9+ records resolved URLs more strictly. Pair that with lockfile-lint so a resolved tarball from registry.npmjs.org for an internal name fails CI.
  • Do not merge manifests. A private proxy that unions your names with the public index reintroduces the original bug. Disable upstream proxying for your scope.

Trusted publishing with OIDC (generally available in 2025) is the follow-on for your public packages: CI publishes without a long-lived token. It does not bind a private scope. You still need the .npmrc line.

How do you configure .npmrc and package.json?

Publish every internal library under a scope you own. Bind that scope in a .npmrc that is committed at the repo root. Keep tokens out of that file.

# .npmrc – committed. Tokens stay in the user or CI environment.
@yourco:registry=https://npm.internal.yourco.com/
//npm.internal.yourco.com/:always-auth=true
registry=https://registry.npmjs.org/
{
"name": "@yourco/internal-auth",
"version": "1.4.2",
"private": true,
"publishConfig": {
"registry": "https://npm.internal.yourco.com/"
}
}

private: true stops npm publish from pushing an app or a workspace root to the public registry by accident. Libraries you do publish internally still use the scoped name and publishConfig.registry. Applications that only consume internals still need the same .npmrc so npm install in a fresh clone or a clean CI image does not fall through to npmjs.org.

Create the @yourco organization on the public registry even if you never publish there. If the public name is taken, change the internal scope to one you can reserve, and update every import. For leftover unscoped names, publish an empty placeholder on npmjs.org or migrate them to the scope. A placeholder is a stopgap; a bound scope is the design.

Verify resolution in CI before you trust the lockfile:

Terminal window
npx lockfile-lint --path package-lock.json \
--allowed-hosts npm.internal.yourco.com registry.npmjs.org \
--validate-https

A @yourco/* package whose resolved URL is registry.npmjs.org is a failed control, not a successful install.

How do you configure private packages in Python and Go?

The npm answer is a bound scope. The other two ecosystems reach the same goal differently, and the difference is worth knowing if you run a polyglot monorepo.

Python has no scope concept, so the control is the index itself. Replace --extra-index-url with index-url in a committed config, and make your internal index proxy PyPI rather than sitting beside it:

# pip.conf / pip.ini – one index, which proxies PyPI upstream.
[global]
index-url = https://pypi.internal.yourco.com/simple

With uv, the equivalent is an index marked as the only source for your own names:

pyproject.toml
[[tool.uv.index]]
name = "internal"
url = "https://pypi.internal.yourco.com/simple"
default = true

Then pin hashes (uv lock, or pip-compile --generate-hashes) and install with --require-hashes. A higher public version cannot win against a pinned hash, which is the property --extra-index-url gives away.

Go resolves this in the module path. An internal module is github.com/yourco/internal-auth, a name only you can publish under, so there is no public name to squat. What you configure is that the proxy and checksum database should not be consulted for it:

Terminal window
go env -w GOPRIVATE=github.com/yourco/*

GOPRIVATE sets GONOPROXY and GONOSUMDB together: fetch those modules directly from the VCS host, and do not compare them against sum.golang.org. Everything else still goes through the public proxy and is still checksum-verified, and go.sum pins what you resolved. Keep GOPRIVATE as narrow as the prefix you actually own. Widening it to github.com/* silently turns off checksum verification for every dependency you have.

How else do you reduce dependency confusion risk?

Software composition analysis reports known CVEs. It does not report "this name is unreserved on npmjs.org." You still do the inventory work.

Verify the source. Check the maintainer, the spelling, the download and star counts, and the signature or checksum. Typosquat names are common. Fetch remote URLs over HTTPS.

Question maintenance. Read the release history. If a maintainer email domain is expired, treat the package as hostile until proven otherwise. Tools such as JFrog's npm_domain_check automate the WHOIS lookup. Ownership changes are a review trigger.

Evaluate necessity. Drop unused dependencies. A package under 35 lines is a trivial package: vendor the function with the license comment instead of importing it. Inspect what the package reads (filesystem, network, environment, shell) and keep that set at the minimum it claims to need.

Pin versions. Lockfiles exist so a higher confused version cannot slide in. For a Git dependency, pin a commit or tag, not a branch.

Remove public mentions of private names. Unpublish accidental public copies. Restrict repos that leaked package.json. If you cannot remove a mention, change the internal name and reserve the new one.

Reserve the namespace. Claiming @yourco on the public registry is the highest-leverage control in this article. Predictable internal names (company-auth, company-billing) without a reserved public twin are how Birsan's list was built.

Socket (which Arcjet uses on its own GitHub repos) flags install scripts and unexpected capability use. Dependabot keeps you current on advisories. Neither replaces a bound scope and a lockfile that you lint.

The installer is not malicious. It is obedient. If two registries can answer the same name, you have already chosen the trust model. Make that choice explicit in .npmrc, or an attacker will make it for you.

Frequently asked questions

What is a dependency confusion attack?

It is a supply-chain attack where a public package with the same name as an internal one is installed instead, usually because the installer prefers the higher version when more than one registry can answer the name.

How did Alex Birsan find private package names?

In February 2021 he used public sources: GitHub, forums, JavaScript bundles, package.json files, and packages accidentally published to the public registry.

Is pip `--extra-index-url` safe for private packages?

No. CVE-2018-20225 documents that pip also searches PyPI and prefers the highest version. The pip project marked it WONTFIX. Use a single --index-url you control, or pin hashes.

What is the npm fix for internal packages?

Publish them under a scope you reserve on npmjs.org, bind @yourco:registry= in a committed .npmrc, set private: true on apps, and lint the lockfile so internal names do not resolve to registry.npmjs.org.

Is Go affected by dependency confusion?

Much less, because a module path such as github.com/yourco/internal-auth is a name only you can publish under, and go.sum pins what you resolved. Set GOPRIVATE to your own prefix so those modules bypass the public proxy and checksum database. Keep the pattern narrow: widening it turns off checksum verification for everything it matches.

Application security in your code

Protect your application with Arcjet

Get rate limits, bot detection, and attack blocking in your request handlers.