Home › Security › Dependency Confusion — Lock Registries and Pin Hashes
Intermediate 5 min · September 23, 2026

Dependency Confusion — Lock Registries and Pin Hashes

Dependency confusion swaps your private packages for public lookalikes.

N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Production
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
Before you start⏱ 17 min
  • ✓A project with private packages plus public dependencies
  • ✓Access to your registry or feed configuration
  • ✓Basic familiarity with lockfiles in your ecosystem
 ● Production Incident 🔎 Debug Guide
⚡Quick Answer
  • Dependency confusion happens when your build resolves a private package name from a public registry serving a lookalike version
  • Attackers squat the names of internal packages on public registries, then wait for your installer to prefer the higher public version
  • Point internal names at a private registry or virtual feed that always wins, and never let private names resolve publicly
  • Commit lockfiles and pin artifact hashes so resolution results are frozen, reviewed, and reproducible on every machine
  • Alert on any private-named package arriving from a public source — that event should never happen by design
✦ Definition~90s read
What is Dependency Confusion in Package Registries?

Dependency confusion (also called substitution attack) exploits how package installers resolve names across multiple registries. Tools like npm, pip, Maven, and NuGet let you configure several sources: a private feed for internal libraries plus the public registry for everything else.

★
Picture a company cafeteria that orders custom-labeled coffee from its own kitchen — but the purchasing clerk also accepts deliveries from any stranger at the loading dock.

When a name exists in both places — or when the installer can't tell which source should win — many clients default to the highest version number regardless of source. Whoever publishes the biggest number wins the build.

Attackers abuse this by discovering internal package names and claiming them publicly. Names leak through public manifests, bundles, build logs, and slides. Once known, a lookalike with an inflated version costs nothing to publish, and unprotected builds pull it in — often executing install scripts with CI privileges.

The defense has three cooperating layers. First, control resolution: scope internal names to your private registry or front everything with a virtual feed that serves private packages first. Second, reserve names: claim internal names publicly as placeholders so nobody else can.

Third, freeze results: commit lockfiles that record exact resolved versions and artifact hashes, verify hashes at install time, and review lockfile diffs like code — because a lockfile change is a supply-chain change.

None of these layers is exotic; together they're decisive. Scoped resolution decides the winner deterministically, name reservation removes the attacker's foothold, and hash-pinned lockfiles mean a substitution can't slip in silently even if resolution is somehow subverted. The rest of this guide makes each layer concrete for the ecosystems you actually run.

Plain-English First

Picture a company cafeteria that orders custom-labeled coffee from its own kitchen — but the purchasing clerk also accepts deliveries from any stranger at the loading dock. An outsider prints matching labels and drops off doctored bags, which the staff brews unquestioned. That's dependency confusion: your build orders internal packages yet accepts same-named substitutes from the public internet. The fix is a receiving policy: internal labels come only from the internal kitchen, seal verified.

Your build pulls hundreds of packages every day. Most come from public registries; a few are private libraries your team built — billing helpers, auth wrappers, shared UI kits. The installer resolves each name to a file, and for private names it checks your private feed first. Or does it? By default, most ecosystems check every configured source and pick the highest version number — including the public registry, where anyone can publish anything under names you haven't claimed.

That default is the dependency confusion hole. An attacker who learns (or guesses) your internal package names publishes lookalikes on the public registry with inflated version numbers. Your next build prefers the attacker's 99.0.0 over your internal 2.4.1, and their code runs with your build's privileges: reading secrets, exfiltrating source, poisoning releases. No phishing, no perimeter breach — just the installer doing exactly what it was configured to do.

This guide explains how private names leak into public view, how scoped registries and virtual feeds force private-first resolution, and why lockfiles with hash pinning turn resolution from a live gamble into a frozen, reviewable fact. Defensive configuration throughout — no attack tooling, just the receiving policy that keeps strangers out of your loading dock.

How Private Package Names Leak Into Public View

Attackers can't squat what they can't name, so the first half of dependency confusion is reconnaissance — and your organization is probably publishing its internal names in several places right now. Public repos with leftover private dependencies in package.json. Frontend bundles whose import paths name internal modules. Source maps that map minified code back to internal file trees. Build logs pasted into public issues. Conference slides with screenshots of directory listings. Each leak is small; together they hand over your namespace.

Guessing fills the gaps. Internal packages follow boring conventions — companyname-utils, platform-auth, shared-components — and attackers enumerate the obvious variants automatically. Short, generic names are the worst: they collide with real public packages and get claimed by opportunists regardless of targeting. If your internal name is also a plausible public name, assume it's already taken or about to be.

Treat name exposure as inevitable and design accordingly. Inventory every internal package name across ecosystems, claim each on the corresponding public registry as a reserved placeholder (most registries support this), and monitor for new claims matching your patterns. Reservation is cheap insurance — it removes the attacker's foothold — but it's the outer fence, not the wall. Resolution controls decide what your builds actually install, and those come next.

BASH
1
2
3
4
5
6
7
# Audit: every internal name must be claimed — no squat window left open
for name in billing-utils platform-auth shared-components; do
  echo "== @acme/$name =="
  npm view "@acme/$name" version 2>&1 | head -2
done
# Same check on the Python side for shared library names
pip index versions acme-billing-utils 2>&1 | head -3
📊 Production Insight
A contractor's public dotfiles repo contained the company's .npmrc with the private feed URL and three internal package names. Squats appeared within a week. Audit what leaves with contractors, not just employees.
🎯 Key Takeaway
Internal names leak through manifests, bundles, logs, and slides. Inventory them, claim public placeholders, and assume guessing covers the rest.

Scoped Registries and Virtual Feeds That Prefer Private

The core fix is making resolution deterministic: internal names resolve from your private feed, always, regardless of what the public registry offers. npm scopes are the cleanest mechanism — map @acme/* to your registry in .npmrc and no public package can shadow those names, because scoped resolution never consults the public registry for your scope. Python's equivalent is stricter index configuration: a private index as the primary source, ideally behind a proxy that serves private packages first and fetches the rest upstream.

Virtual feeds (repository managers like Artifactory, Nexus, or cloud artifact registries) generalize this across ecosystems: one internal endpoint that checks private storage first and proxies public registries second. Builds point only at the virtual feed and never at the public internet directly. The ordering inside the feed is the security policy — private-first is the only safe mode — so review it like firewall rules, not like a performance tweak.

Verify, don't trust. After configuring, run a resolution dry-run and confirm each internal name maps to your feed's URL. The .npmrc snippet below shows the shape for npm: scope pinned to the private registry, public registry explicit for the rest, scripts disabled in CI. Repeat the exercise per ecosystem — pip, Maven, NuGet each have their own knobs, and an attacker only needs the one you forgot.

.npmrc (committed at repo root)BASH
1
2
3
4
5
6
7
8
9
# Scope ALL internal packages to the private feed (private always wins)
@acme:registry=https://artifacts.example.com/npm-internal/
registry=https://registry.npmjs.org/

# Auth for the private feed comes from CI secrets, never committed
# //artifacts.example.com/npm-internal/:_authToken=${ARTIFACTS_TOKEN}

# Verify resolution: every @acme/* URL must point at our feed
npm view @acme/billing-utils dist.tarball
📊 Production Insight
A team configured the private feed but left it as a fallback behind the public registry 'for speed.' The faster source won every race. Private-first isn't a preference — it's the entire control. Order matters more than intent.
🎯 Key Takeaway
Pin internal names to your feed with scopes or private-first virtual feeds, and prove it with resolution dry-runs per ecosystem.

Lockfiles and Hash Pinning That Freeze Resolution

Resolution controls decide where names come from; lockfiles decide what exactly gets installed. A lockfile records the resolved version, source URL, and integrity hash of every package in the tree — including transitive dependencies you'd never name directly. Committed and enforced, it converts each build from a live negotiation with registries into a replay of reviewed decisions. An attacker's 99.0.0 can't win if the build never asks who's newest.

Enforcement is what separates protection from paperwork. Commit the lockfile, install with --frozen-lockfile (or your ecosystem's equivalent) in CI, and fail the build on any deviation. Require integrity hashes on every entry so a compromised mirror serving altered tarballs under correct versions still fails verification. Review lockfile diffs in pull requests with fresh eyes: source-registry changes, version jumps that skip internal numbering, and new transitive dependencies are the three signals that deserve a security look before merge.

Watch the escape hatches. Lockfile regeneration flags (--force, update-all) re-run resolution and can bless a substitution as the new truth; restrict them to explicit dependency-update workflows with mandatory review. The package.json snippet below shows the npm side: exact integrity metadata that makes tampering detectable. Pair it with a CI check that fails on registry-source changes for internal names, and confusion attempts become build failures instead of incidents.

package-lock.json (excerpt — committed)JAVASCRIPT
1
2
3
4
5
6
7
8
9
10
11
{
  "name": "billing-service",
  "lockfileVersion": 3,
  "packages": {
    "node_modules/@acme/billing-utils": {
      "version": "2.4.1",
      "resolved": "https://artifacts.example.com/npm-internal/billing-utils-2.4.1.tgz",
      "integrity": "sha512-EXAMPLEHASH-must-match-feed-artifact-exactly=="
    }
  }
}
Try it live
📊 Production Insight
A scheduled 'npm update' job regenerated the lockfile nightly with no review. It blessed the squatted 99.0.0 as official on a Friday night. Automated updates without diff review are automated compromise acceptance.
🎯 Key Takeaway
Commit lockfiles, enforce frozen installs in CI, pin integrity hashes, and review every lockfile diff as a supply-chain decision.

Namespaces, Scopes, and Reserved Names

Beyond your own feed, use every ownership mechanism your ecosystem offers. npm scopes and organization namespaces, PyPI project ownership, Maven groupIds under domains you control, NuGet reserved prefixes — each of these converts a name from first-come-first-served into yours-by-right. Register them early, including the variants (acme, acme-inc, acmeinc) that squatters reach for when the exact name is taken.

Publish reserved placeholders for internal names on public registries: a minimal package whose only job is occupying the name with a clear 'private to Acme, do not use' readme. This isn't security by itself — placeholders can be Deprecation-ignored by determined attackers only if your resolution is broken, and with proper scoping they can never win — but it closes the casual-squat window and signals ownership to anyone who stumbles on the name.

Document the namespace policy where developers will actually read it: the new-service template, the package-publishing runbook, the contractor onboarding checklist. Every new internal library should be born scoped, reserved, and feed-pinned — never migrated later, because 'later' is where the incident in this article lived. A ten-minute naming checklist at creation beats a four-hour credential rotation after publication.

📊 Production Insight
A team reserved exact names but not common variants. Attackers published acme_billing-utils (underscore) and caught typo-prone imports in two repos. Reserve the typo space around critical names, not just the names.
🎯 Key Takeaway
Own your namespaces in every ecosystem, publish reserved placeholders, and make scoping part of library creation — not a later migration.

CI Checks That Catch Substitution Before Deploy

Configuration rots — feeds get reordered during migrations, scopes get dropped in refactors, lockfiles get force-regenerated at midnight. CI checks are the ratchet that holds your posture between audits. Build three gates: resolution assertion (every internal name resolves to your feed), hash verification (installed artifacts match the lockfile), and diff review triggers (registry-source or major-version changes require security sign-off).

Make the gates loud and specific. A failure should name the package, the expected source, and the unexpected one — 'billing-utils resolved to public registry, expected artifacts.example.com' — so any developer can act without a security background. Page on private-names-from-public-sources; that's not a flaky test, it's an active attack or a broken control, and both deserve immediacy.

The pipeline snippet below shows the resolution gate in portable form: extract each internal package's resolved URL from the lockfile and assert it belongs to your feed. Ten lines, runs in seconds, and it's the check that would have stopped the incident above at the pull-request stage — before 60 agents executed anything. Add the companion monitor outside CI: watch public registries for new claims on your names so you're warned even before a build runs.

BASH
1
2
3
4
5
6
7
8
9
10
# CI gate: every @acme/* entry must resolve to the private feed
FEED='artifacts.example.com/npm-internal'
BAD=$(node -e "
const lock = require('./package-lock.json');
const bad = Object.entries(lock.packages || {})
  .filter(([p, m]) => p.includes('@acme/') && !(m.resolved || '').includes('$FEED'))
  .map(([p]) => p);
console.log(bad.join('\n'));")
[ -z "$BAD" ] && echo 'OK: all internal packages resolve privately' \
  || { echo "BLOCKED: public resolution for: $BAD"; exit 1; }
⚠ Never let private names resolve publicly
Any build that can fetch an internal name from the public internet is one squatted version away from executing attacker code. Private-first resolution plus a CI gate isn't optional hardening — it's the control the whole defense rests on.
📊 Production Insight
A resolution gate existed but only ran on release branches. The squatted package merged to main, ran on CI agents for hours, and was caught at release time — after execution. Run supply-chain gates on every branch, every push.
🎯 Key Takeaway
Gate every build on private resolution and hash integrity, page on violations, and monitor public registries for new claims on your names.

Responding When a Confused Dependency Ships

If a substitution already executed, you're in incident response, not configuration review. Assume the payload ran with full CI privileges: revoke and rotate every secret visible to the build environment — cloud credentials, registry tokens, signing keys — because install scripts exfiltrate first and ask never. Quarantine affected agents and rebuild from clean images rather than 'cleaning' them; persistence through build caches and shared volumes is the norm, not the exception.

Scope with artifacts, not assumptions. Identify every build that resolved the malicious version (lockfile history and feed access logs), every artifact those builds produced, and every environment those artifacts reached. Redeploy from known-good builds, don't patch forward from tainted ones. Preserve the malicious package and full logs for analysis before purging — you need its behavior (what it stole, where it sent it) to scope notifications and regulatory obligations.

Then fix the system, not just the symptom. The incident above ended with scoping, placeholders, private-first feeds, frozen lockfiles, and script restrictions — the full stack from this guide. Write the retrospective around the question that matters: which layer should have caught this, and why didn't it? A substitution that ships is never one missing control; it's the gap between controls you thought overlapped.

📊 Production Insight
A team rotated app secrets but forgot the CI runner's own cloud identity, which the payload had also stolen. The attacker returned through the runner a week later. Inventory every credential the build can see — including the runner's — before declaring rotation complete.
🎯 Key Takeaway
Assume full CI-secret compromise, rebuild agents clean, redeploy from known-good artifacts, then close every layer gap the retrospective finds.
● Production incidentPOST-MORTEMseverity: high

A Squatted Internal Name Ran Code on 60 Build Agents in 4 Hours

Symptom
On a Tuesday morning, a routine dependency update PR showed a lockfile diff nobody recognized: the internal package @acme/billing-utils had jumped from 2.4.1 to 99.0.0 with an unknown tarball hash. Investigation revealed the package had resolved from the public registry, not the private feed — and its install script had executed on all 60 CI agents over the previous 4 hours, exfiltrating environment variables (including cloud credentials and npm tokens) to an external host. Three production deploys in that window shipped bundles containing the attacker's code. The internal name had never been claimed on the public registry, and the private feed had no priority over public for unscoped lookups.
Assumption
The team assumed private names stayed private because they were only documented in the internal wiki. But the name appeared in a public GitHub repo's package.json (a contractor's dotfiles), in frontend bundle source maps, and in a conference talk's screenshot. They also assumed the lockfile protected them — yet the lockfile had been regenerated with --force during a version-bump sprint, silently accepting the public 99.0.0 as the newest truth.
Root cause
npm resolved the unscoped internal name across all configured registries and selected the highest version: the attacker's 99.0.0 on the public registry beat the internal 2.4.1. The private feed was configured as an additional source, not an authoritative one, and lifecycle scripts ran automatically at install time with CI privileges. Detection came only from a sharp reviewer questioning a version number that couldn't exist internally.
Fix
Immediate: revoke all exposed credentials and tokens, quarantine the 60 agents, rebuild them from clean images, and redeploy the three tainted releases from known-good artifacts. Structural: move internal packages under the @acme scope pinned to the private registry, front registries with a virtual feed in private-first mode, claim all internal names publicly as reserved placeholders, commit lockfiles with integrity hashes, and disable install scripts in CI with --ignore-scripts plus an allowlist for the two packages that genuinely need them.
Key lesson
  • Unclaimed internal names are public attack surface: squat your own names on public registries before strangers do.
  • Highest-version-wins across mixed sources is the vulnerability. Make private feeds authoritative for internal names instead of merely additional.
  • Lockfile diffs are supply-chain reviews: a version that can't exist internally should fail the build, not await a sharp-eyed reviewer.
Production debug guideFive defensive checks, from finding leaks to freezing resolution.5 entries
Symptom · 01
You don't know which internal names are exposed
→
Fix
List every internal package name, then search public surfaces: GitHub code search, public repos' manifests, published bundles and source maps, build logs, and docs. For npm, check whether each name (and scope) is claimed on the public registry; for pip, check PyPI. Every unclaimed internal name is a finding — claim it as a reserved placeholder today, then fix resolution so the placeholder could never win anyway.
Symptom · 02
Your installer can resolve private names from public sources
→
Fix
Reconfigure for private-first resolution: npm scopes mapped to your registry in .npmrc, pip --index-url pointing at your feed with --extra-index-url only where unavoidable (prefer per-package requirements with --index-url pinning or a proxy feed), Maven repositories ordered with internal first and releases isolated. Verify with a dry-run install showing each internal name resolving to your feed's URL, not the public one.
Symptom · 03
Builds don't have committed lockfiles or hash verification
→
Fix
Commit package-lock.json / pnpm-lock.yaml / poetry.lock / Pipfile.lock and enforce --frozen-lockfile (or equivalent) in CI so unreviewed resolution changes fail the build. Require integrity hashes on every entry and review lockfile diffs in PRs with the same seriousness as code — automate a check that flags version jumps (like 2.x to 99.x) and source-registry changes for mandatory security review.
Symptom · 04
Install scripts execute arbitrary code during every build
→
Fix
Disable lifecycle scripts in CI (--ignore-scripts for npm, hardened install flags elsewhere) and allowlist only the packages that genuinely need postinstall steps, pinned by hash. Audit the allowlist quarterly. This doesn't prevent confusion but converts a won resolution from instant code execution into a inert wrong file — a vastly smaller blast radius while you fix the source.
Symptom · 05
You need ongoing assurance, not a one-time fix
→
Fix
Add a CI job that asserts every internal-named artifact's resolved URL belongs to your feed and its hash matches the lockfile; alert (page, don't email) on any private name arriving from a public source. Monitor public registries for new claims on your names and internal patterns, and re-run the exposure search after every repo goes public or contractor engagement ends.
Dependency Confusion Defenses at a Glance
Root CauseHow to ConfirmFixPrevention
Public registry wins on version numberDry-run resolution shows internal names mapping to public URLsScope names to private feed; virtual feed in private-first modeCI resolution gate asserting feed URLs on every push
Internal names unclaimed publiclyRegistry search shows your names available or squattedClaim reserved placeholders for all internal names and variantsNamespace checklist at library creation; registry monitoring
Lockfiles missing or force-regeneratedCI installs without frozen-lockfile; diffs show unreviewed jumpsCommit lockfiles with hashes; enforce frozen installsDiff-review rules flagging source changes and version jumps
Install scripts execute on every buildAudit shows lifecycle scripts running with CI privilegesDisable scripts in CI; hash-pinned allowlist for exceptionsQuarterly allowlist review; minimal-privilege build identities
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
for name in billing-utils platform-auth shared-components; doHow Private Package Names Leak Into Public View
.npmrc (committed at repo root)@acme:registry=https://artifacts.example.com/npm-internal/Scoped Registries and Virtual Feeds That Prefer Private
package-lock.json (excerpt — committed){Lockfiles and Hash Pinning That Freeze Resolution
FEED='artifacts.example.com/npm-internal'CI Checks That Catch Substitution Before Deploy

Key takeaways

1
Highest-version-wins across mixed sources is the vulnerability; make private feeds authoritative.
2
Inventory internal names and claim public placeholders before attackers squat them.
3
Scope internal names to your feed so public registries are never consulted for them.
4
Commit hash-pinned lockfiles, enforce frozen installs, and review every lockfile diff.
5
Disable install scripts in CI with a hashed allowlist to shrink what a substitution can execute.
6
Gate every push on resolution and integrity, and monitor public registries for your names.

Common mistakes to avoid

5 patterns
×

Adding the private feed as a fallback behind the public registry

Symptom
Resolution races go to the public side on version number, and internal packages silently arrive from the internet.
Fix
Configure private-first ordering (scopes, feed priority) and prove it with resolution dry-runs.
×

Assuming internal names are secret

Symptom
Names surface in public manifests, bundles, and screenshots while the team believes obscurity protects resolution.
Fix
Inventory internal names, claim public placeholders, and fix resolution so secrecy is unnecessary.
×

Regenerating lockfiles in automated jobs without review

Symptom
Nightly updates bless substituted versions as the new official truth with no human ever seeing the diff.
Fix
Restrict regeneration to reviewed update workflows and require security sign-off on source or major-version changes.
×

Running install scripts with full CI privileges

Symptom
A single won resolution executes attacker code with access to every secret the build can see.
Fix
Disable scripts in CI, allowlist hashed exceptions, and minimize build-identity permissions.
×

Gating only release branches with supply-chain checks

Symptom
Substitutions execute on feature-branch CI for hours before release-time gates ever run.
Fix
Run resolution and hash gates on every branch and every push — execution happens long before release.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is dependency confusion in one paragraph?
Q02SENIOR
Why don't lockfiles alone prevent dependency confusion?
Q03SENIOR
How do scoped registries fix resolution for npm?
Q04SENIOR
A lockfile shows an internal package jumping 2.4.1 to 99.0.0 from a new ...
Q05SENIOR
Design a multi-ecosystem defense for npm, pip, and Maven together.
Q01 of 05JUNIOR

What is dependency confusion in one paragraph?

ANSWER
When installers resolve names across private and public sources by highest version, attackers publish lookalikes of internal package names with inflated versions on public registries. Unprotected builds pull the attacker's code, often executing install scripts with build privileges.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
Does dependency confusion affect pip and Maven or just npm?
02
Are placeholders on public registries enough protection?
03
Should I disable install scripts entirely?
04
How do I handle dependencies that exist only on the public registry?
05
What's the difference between a lockfile and version pinning?
06
How often should I review the install-script allowlist?
N
Naren Founder & Principal Engineer

20+ years shipping production backend systems. Everything here is grounded in real deployments.

Follow
✓ Verified
production tested
September 26, 2026
last updated
2,085
articles · all by Naren
🔥

That's Supply Chain. Mark it forged?

5 min read · try the examples if you haven't

←
Previous
Log4Shell: How JNDI Lookup Became Remote Code Execution
2 / 2 · Supply Chain
Next
Secrets Leaked in Git History — Detect and Purge
→