Dependency Confusion Attacks and How to Prevent Them
How dependency confusion attacks exploit package managers to inject malicious code — and the concrete defenses that protect your supply chain.

In February 2021, a security researcher demonstrated that he could execute code inside the networks of Apple, Microsoft, PayPal, and dozens of other companies by exploiting a vulnerability in how package managers resolve dependencies. The technique is called dependency confusion, and it works because most package managers check public registries before — or alongside — private ones.
If your organization publishes internal packages to a private registry, you are vulnerable unless you have taken explicit steps to prevent it.
How the Attack Works
The attack targets the gap between private and public package registries. When a project depends on @company/auth-utils from a private registry, the package manager might also check the public npm registry. If an attacker publishes a package named auth-utils (without the scope) on npm with a higher version number, some configurations will prefer the public version.
// ❌ Vulnerable package.json — unscoped private package names
{
"dependencies": {
"auth-utils": "^1.2.0",
"payment-service-sdk": "^3.0.0",
"internal-logger": "^2.1.0"
}
}
// An attacker publishes "auth-utils@99.0.0" to npm
// Package manager sees the higher version on the public registry
// Installs the attacker's malicious package instead// ✅ Scoped packages with registry pinning
{
"dependencies": {
"@yourcompany/auth-utils": "^1.2.0",
"@yourcompany/payment-service-sdk": "^3.0.0",
"@yourcompany/internal-logger": "^2.1.0"
}
}
// Scoped packages (@yourcompany/*) route to your private registry
// Attacker cannot claim your organization's scope on npmThe attack relies on three conditions: unscoped internal package names, a package manager that queries both registries, and no version pinning or integrity checks. Remove any one of these, and the attack fails.
Defense Layer 1: Scope All Internal Packages
The simplest and most effective defense is to use npm scopes for all internal packages. When you own the @yourcompany scope on the public npm registry, no one else can publish packages under that scope.
# Claim your organization's scope on npm (even if you never publish there)
npm login --registry=https://registry.npmjs.org
npm org create yourcompany
# Now no attacker can publish @yourcompany/* packages publicly// ❌ Internal package without scope
// package.json of your internal library
{
"name": "feature-flags-sdk",
"version": "1.3.0"
}
// Attacker can publish "feature-flags-sdk@99.0.0" on npm
// ✅ Internal package with scope
{
"name": "@yourcompany/feature-flags-sdk",
"version": "1.3.0"
}
// Only members of @yourcompany npm org can publish thisEven if you never publish packages to the public registry, claim your org scope defensively. It is free and blocks the most common attack vector.
Defense Layer 2: Registry Configuration
Configure your package manager to route internal scopes to your private registry and everything else to the public registry. Never let the package manager "search" both registries for the same package.
# .npmrc — explicit registry routing
# All @yourcompany packages come from your private registry
@yourcompany:registry=https://npm.yourcompany.com/
# Everything else comes from the public registry
registry=https://registry.npmjs.org/
# Optional: require authentication for private registry
//npm.yourcompany.com/:_authToken=${NPM_PRIVATE_TOKEN}# .yarnrc.yml (Yarn Berry / Yarn 2+)
npmScopes:
yourcompany:
npmRegistryServer: "https://npm.yourcompany.com/"
npmAuthToken: "${NPM_PRIVATE_TOKEN}"
npmRegistryServer: "https://registry.npmjs.org/"// ❌ Single registry fallback — dangerous
// .npmrc with only:
// registry=https://npm.yourcompany.com/
// If private registry is down, npm falls back to public registry
// Attacker waits for an outage, or the fallback fetches malicious packages
// ✅ Explicit routing — no fallback ambiguity
// .npmrc with scoped registries as shown above
// @yourcompany/* → private registry (no fallback)
// everything else → public npm (no confusion)Defense Layer 3: Lock Files and Integrity Checks
Lock files pin exact versions and include integrity hashes. Even if a package manager resolves a malicious version, the integrity hash mismatch causes installation to fail.
// package-lock.json includes integrity hashes
{
"node_modules/@yourcompany/auth-utils": {
"version": "1.2.0",
"resolved": "https://npm.yourcompany.com/@yourcompany/auth-utils/-/auth-utils-1.2.0.tgz",
"integrity": "sha512-abc123def456..."
}
}# CI should always use frozen lockfile installation
# npm
npm ci # Fails if lock file is out of sync with package.json
# yarn
yarn install --frozen-lockfile
# pnpm
pnpm install --frozen-lockfile# ❌ Running 'npm install' in CI
# This can update the lock file, potentially pulling in new versions
npm install
# ✅ Running 'npm ci' in CI
# This installs exactly what the lock file specifies
# Fails fast if lock file and package.json disagree
npm ciCommit your lock file to version control and enforce npm ci (or equivalent) in all CI pipelines. Any developer who runs npm install and gets unexpected lock file changes should investigate before committing.
Defense Layer 4: Automated Auditing
Run dependency audits as part of your CI pipeline. Check for known vulnerabilities and unexpected package sources.
# GitHub Actions — dependency audit step
name: Security Audit
on:
pull_request:
paths:
- 'package.json'
- 'package-lock.json'
- '.npmrc'
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v3
- uses: actions/setup-node@v3
with:
node-version: '18'
- name: Install with frozen lockfile
run: npm ci
- name: Run npm audit
run: npm audit --audit-level=high
- name: Check package sources
run: |
# Verify all @yourcompany packages resolve to private registry
grep -E '"resolved": "https://npm.yourcompany.com' package-lock.json \
| wc -l
# Verify NO @yourcompany packages resolve to public registry
if grep -E '@yourcompany.*registry.npmjs.org' package-lock.json; then
echo "ERROR: Internal package resolving to public registry!"
exit 1
fi// Custom script to validate package sources
import { readFileSync } from 'fs';
interface PackageLockEntry {
version: string;
resolved: string;
integrity: string;
}
function auditPackageSources(lockfilePath: string): void {
const lockfile = JSON.parse(readFileSync(lockfilePath, 'utf-8'));
const packages = lockfile.packages || {};
for (const [name, meta] of Object.entries<PackageLockEntry>(packages)) {
if (name.includes('@yourcompany/')) {
const isPrivateRegistry = meta.resolved?.startsWith(
'https://npm.yourcompany.com'
);
if (!isPrivateRegistry) {
console.error(
`SECURITY: ${name} resolves to unexpected registry: ${meta.resolved}`
);
process.exit(1);
}
}
}
console.log('All internal packages resolve to private registry.');
}
auditPackageSources('package-lock.json');Defense in Depth Summary
No single defense is sufficient. Layer them:
defense_layers:
1_scoping:
action: "Scope all internal packages under @yourcompany"
blocks: "Attacker cannot claim your scope on public registry"
2_registry_routing:
action: "Pin scopes to specific registries in .npmrc"
blocks: "Package manager never queries wrong registry"
3_lock_files:
action: "Use npm ci with integrity hashes in CI"
blocks: "Even if resolution is wrong, hash mismatch stops install"
4_auditing:
action: "Automated CI checks for package source URLs"
blocks: "Catches misconfigurations before they reach production"
5_monitoring:
action: "Alert on new packages appearing in lock file diffs"
blocks: "Human review of unexpected dependency changes"Key Takeaways
- Dependency confusion exploits package manager resolution order — public packages with higher versions override private ones
- Scope all internal packages (
@yourcompany/*) and claim the org scope on public registries defensively - Pin scoped packages to your private registry in
.npmrc— never let package managers search both registries for the same name - Use
npm ciin CI pipelines — frozen lockfiles with integrity hashes catch resolution mismatches - Automate source auditing — verify that internal packages always resolve to your private registry, not to npm
- Layer your defenses — scoping, registry routing, lock files, and auditing together provide robust protection


