Back to publishing & release Automate semantic versioning Manage release channels Harden the supply chain

Preventing Dependency Confusion Attacks

Dependency confusion exploits a simple ambiguity: your projects depend on internal package names that live on a private registry, and an attacker publishes packages with the same names on the public registry. If a package manager can ever resolve those names from the public registry — because a scope is not mapped, a proxy prefers public versions, or an unscoped internal name was never claimed — it installs the attacker's code, often with a high version number to win range resolution. This guide explains the mechanics, then closes each path with scoped names, explicit routing, registry-side controls and CI checks.

How the attack works

An attacker learns internal package names from leaked package.json files, error messages, JavaScript bundles or job postings. They publish packages with those names on the public registry, usually at a version such as 99.0.0, with an install script that exfiltrates environment variables. The attack succeeds wherever resolution for that name can reach the public registry. Because the payload usually runs at install time, it executes on developer laptops and CI runners — exactly where credentials for source control, cloud accounts and registries are most concentrated — before any application code or test ever runs.

A dependency confusion install A build machine requests an internal unscoped package; the proxy consults both the internal registry and the public registry, picks the attacker's higher version, and the install script runs. CI install proxy registry internal registry public registry GET acme-logger (^1.2.0 or latest) versions: 1.4.2 versions: 99.0.0 (attacker) highest: 99.0.0
The attack needs only one resolution path that can reach the public registry for an internal name.

Common vulnerable setups:

  • Unscoped internal packages (acme-logger) resolved through a proxy that merges internal and public registries and returns the highest version.
  • Scoped internal packages without a scope mapping on some machine — a new laptop, a CI image, a Docker build without the project .npmrc — which fall back to the public registry.
  • An unclaimed scope on the public registry, so anyone can register the organisation acme and publish @acme/* packages there.

The broader hardening context is covered in Supply-Chain Security Hardening.

Defence 1: scope every internal package

Move internal packages to a scope you control (@acme/logger instead of acme-logger). This is the single most effective structural change, because it turns an open-ended list of names into one namespace you can route, claim and monitor as a unit. Scopes make ownership explicit and make routing possible: a registry mapping applies to the whole scope. Renaming is a one-time migration — publish the scoped name, update consumers, deprecate the unscoped one — covered in Renaming a Package Without Stranding Users.

Defence 2: claim the scope on the public registry

Create the acme organisation on the public npm registry even if you never publish there. Nobody else can then publish @acme/* publicly. Do the same for every scope you use internally, including scopes used only by old projects.

Defence 3: route scopes explicitly, everywhere

Commit a project .npmrc that maps every internal scope to the private registry, so no environment depends on a developer's personal configuration:

registry=https://registry.npmjs.org/
@acme:registry=https://npm.internal.example.com/
//npm.internal.example.com/:_authToken=${INTERNAL_NPM_TOKEN}

Details are in Routing Scopes to Multiple Registries in .npmrc. Make sure Dockerfiles copy the .npmrc before installing, and CI images do not rely on a baked-in user configuration.

Defence 4: configure the proxy not to merge names

If installs go through a proxy registry, configure it so internal names can never be served from upstream:

# Verdaccio config.yaml
packages:
  "@acme/*":
    access: $authenticated
    publish: $authenticated
    # no "proxy:" line — never fetch @acme/* from upstream
  "**":
    access: $all
    proxy: npmjs

Artifactory and Nexus offer equivalent include/exclude patterns on remote repositories, and AWS CodeArtifact's package origin controls block upstream versions of packages published directly — see Publishing to AWS CodeArtifact.

Defences against dependency confusion Compares scoped names, claiming the public scope, explicit scope routing, proxy exclusion rules and lockfile host checks on what they prevent. prevents gap it leaves scoped internal names enables routing by scope needs routing configured claim scope publicly attacker publishing @acme/* unscoped names commit scope routing fallback to public registry machines without the file proxy exclusion rules merged upstream versions clients bypassing the proxy lockfile host checks wrong source reaching main installs before lockfile exists
Layer the defences — each one covers a path the others miss.

Defence 5: verify sources in CI

The lockfile records where every package was resolved from. Fail CI if any internal package resolves from a public host, or if any host outside an allowlist appears:

npx lockfile-lint --path package-lock.json --type npm \
  --allowed-hosts npm registry.npmjs.org npm.internal.example.com \
  --validate-https
# Internal scope must only resolve from the internal registry
grep -A2 '"node_modules/@acme/' package-lock.json | grep '"resolved"' | grep -v 'npm.internal.example.com' && exit 1 || true

lockfile-lint is covered in Configuring lockfile-lint for Supply-Chain Safety. Combined with frozen installs, this means a confused resolution cannot reach main without a failing check.

Defence 6: limit what a malicious package can do

Even with every path closed, assume something may slip through. Installing with scripts disabled (--ignore-scripts, or pnpm 10's default of not running dependency scripts unless allowlisted) removes the most common payload trigger, as described in Blocking Malicious Install Scripts with --ignore-scripts. Keep CI secrets out of install steps: provide publish and deploy credentials only to the steps that need them, so an install-time payload finds nothing worth stealing.

Defence in depth for internal package names Layers from naming and ownership, through routing and registry configuration, to CI verification and limiting install-time execution. 1 Names you own scoped packages; scopes claimed on the public registry 2 Routing committed .npmrc maps every internal scope 3 Registry rules proxy never serves internal names from upstream 4 Verification lockfile-lint host checks and frozen installs in CI 5 Containment no install scripts, no secrets during install
No single control is enough; together they make a confused install both unlikely and harmless.

Finding your exposure

Before changing configuration, find out which of your names are exposed. Three inventories give a complete picture.

Internal names in use. Collect every dependency name from every repository's manifests and lockfiles, and mark those served by your private registry. A short script over lockfiles is more reliable than asking teams, because lockfiles include transitive internal packages that no manifest mentions directly.

Public availability of those names. For each internal name, check the public registry: npm view <name> name returns the name if it exists publicly and fails with a 404 if it does not. Unscoped internal names that are unregistered publicly are the most urgent — anyone can take them today. Names that already exist publicly and are not yours are the second most urgent: some machines may already be installing the public package.

Resolution paths. List every environment that installs dependencies — developer machines, CI images, Docker builds, deployment platforms, dependency bots — and check how each one is configured to reach the private registry. Any environment that relies on a user-level .npmrc rather than the committed project file is a gap.

Monitoring after the fixes

Controls drift: a new repository is created without the project .npmrc, a new internal package is published without a scope, a proxy rule is edited during an upgrade. Add ongoing checks. The lockfile host check in CI covers individual repositories. A scheduled job that queries the public registry for your internal names and scopes — alerting if a new public version of any internal name appears — covers attackers probing for gaps. And registry audit logs, where available, show which clients fetched internal names from unexpected sources. Several commercial supply-chain tools provide these checks, but a scheduled script with a list of names covers the essentials.

Worked example: an audit of internal names

A security team audits a company's repositories and finds 41 internal package names, 12 of them unscoped. Four of those unscoped names are unregistered on the public registry — immediately claimable by anyone. They register placeholder packages for the four names (a minimal package whose README explains it is reserved), claim the company's two scopes as npm organisations, configure the Artifactory remote repository to exclude the internal names and scopes, commit scope routing to every repository's .npmrc, and add lockfile host checks to the shared CI template. Over the next quarter the unscoped packages are renamed into the scope and the old names deprecated.

Prevention and CI/CD guardrails

  • Scope all internal packages, and claim those scopes publicly.
  • Commit scope routing in every repository and copy it into container builds.
  • Exclude internal names from proxy upstreams.
  • Check lockfile hosts in CI and install with frozen lockfiles.

Frequently Asked Questions

Does a lockfile protect me by itself? It protects installs from an existing lockfile, because resolved URLs and integrity hashes are fixed. It does not protect the moment a lockfile is created or updated — a dependency bot or npm install on a misconfigured machine can record the attacker's package. That is why host checks in CI matter.

Is npm's public registry doing anything about this? The registry removes malicious packages when reported, and scoped names prevent squatting within an organisation's scope. The ambiguity itself lives in client and proxy configuration, which only you control.

Should placeholder packages contain code? No. Publish a minimal package with no scripts and a README stating the name is reserved by your organisation.

Does this apply to Yarn and pnpm? Yes. Both resolve scoped packages through configured registries and fall back to the default registry when a scope is unmapped. Yarn Berry uses npmScopes in .yarnrc.yml; pnpm reads .npmrc. The same defences apply.

What should we do if we find an attacker's package with one of our names? Treat any machine that might have installed it as potentially compromised: rotate credentials that were available during installs, report the package to the registry, and follow the incident steps in Responding to a Compromised Dependency.

Related

Supply-Chain Security Hardening