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

Routing Scopes to Multiple Registries in .npmrc

Most organisations end up with more than one registry: the public npm registry for open-source dependencies, GitHub Packages or a corporate registry for internal @acme packages, maybe a partner's registry for a licensed SDK. npm, pnpm and Yarn can route each scope to its own registry with its own credentials, so one install pulls from all of them correctly. Getting the configuration wrong produces 401s, 404s, packages fetched from the wrong source, or tokens sent to the wrong host — and the last two fail silently, which makes them the most dangerous. This guide builds a multi-registry .npmrc, explains how credentials are matched to hosts, and covers the Yarn equivalent and the security implications of routing.

How routing works

A package manager decides where to fetch each package by its name:

  • Scoped packages (@acme/ui) use the registry configured for their scope with @acme:registry=<url>, if one exists.
  • Everything else — unscoped packages and scopes without their own entry — uses the default registry=<url>.

Credentials are then chosen by URL prefix: an entry like //npm.pkg.github.com/:_authToken=... applies to every request whose URL starts with npm.pkg.github.com/. The general private-registry setup is covered in Private Registries and Access Control.

Where each package is fetched from A scoped package with a scope mapping goes to that registry; other scoped and unscoped packages go to the default registry; credentials are matched to each registry by URL prefix. npm install resolves a package does its name have a mapped scope? npm.pkg.github.com token for //npm.pkg.github.com/ @acme/* registry.partner.example token for that host @partner/* registry.npmjs.org anonymous or npm token everything else
Routing is by scope; authentication is by host prefix — both must line up for every registry.

A multi-registry .npmrc

# .npmrc (committed — tokens come from environment variables)

# Default registry for unscoped packages and unmapped scopes
registry=https://registry.npmjs.org/

# Internal packages on GitHub Packages
@acme:registry=https://npm.pkg.github.com/
//npm.pkg.github.com/:_authToken=${GITHUB_PACKAGES_TOKEN}

# A partner's licensed SDK on their registry
@partner:registry=https://registry.partner.example/npm/
//registry.partner.example/npm/:_authToken=${PARTNER_NPM_TOKEN}

# Always send auth to registries that require it for reads
//registry.partner.example/npm/:always-auth=true

Rules that make this work:

  1. The credential key must match the registry URL's host and path prefix, without the protocol. For https://registry.partner.example/npm/, the key is //registry.partner.example/npm/:_authToken. A key for //registry.partner.example/ also matches, because it is a prefix; a key for a different path does not.
  2. Trailing slashes matter. Keep them consistent between the registry value and the credential key.
  3. Tokens come from environment variables, so the file can be committed safely. Developers set them in their shell; CI sets them from secrets.
  4. One registry per scope. A scope cannot be split across registries; if some @acme packages are public and some private, publish them all to one registry that can serve both, or use different scopes such as @acme for public and @acme-internal for private packages.

Verifying the routing

# Which registry does each scope resolve to?
npm config get registry
npm config get @acme:registry
npm config get @partner:registry

# Can each registry authenticate you?
npm whoami --registry https://npm.pkg.github.com/
npm whoami --registry https://registry.partner.example/npm/

# Where did a specific package come from?
npm view @acme/ui dist.tarball
grep -A3 '"node_modules/@acme/ui"' package-lock.json | grep resolved

The resolved URLs in the lockfile are the ground truth: every @acme package should resolve from npm.pkg.github.com, and nothing unexpected should appear there. Because the lockfile is committed and reviewed, a routing mistake also shows up in pull request diffs as a changed host — worth watching for whenever .npmrc changes.

Where packages in one project resolve from A project's dependencies grouped by the registry they are fetched from, with scoped internal and partner packages on their own registries and everything else on the public registry. apps/web dependencies npm.pkg.github.com @acme/ui @acme/api-client internal, GITHUB_PACKAGES_TOKEN registry.partner.example @partner/maps-sdk licensed, PARTNER_NPM_TOKEN registry.npmjs.org react zod date-fns ... public, anonymous
The lockfile's resolved URLs should match this picture exactly.

pnpm and Yarn

pnpm reads the same .npmrc keys, so the file above works unchanged. pnpm also records tarball URLs in pnpm-lock.yaml relative to the configured registry when they match, which keeps lockfiles portable if a registry URL changes.

Yarn Berry ignores .npmrc and uses .yarnrc.yml:

npmRegistryServer: "https://registry.npmjs.org"
npmScopes:
  acme:
    npmRegistryServer: "https://npm.pkg.github.com"
    npmAuthToken: "${GITHUB_PACKAGES_TOKEN}"
    npmAlwaysAuth: true
  partner:
    npmRegistryServer: "https://registry.partner.example/npm"
    npmAuthToken: "${PARTNER_NPM_TOKEN}"

Scope names in npmScopes are written without the @. Keep both files in sync if some contributors or tools still use npm alongside Yarn in the same repository, or better, standardise on one package manager so there is only one routing file to maintain.

Publishing with multiple registries

Publishing follows the same routing: npm publish for @acme/ui goes to the @acme registry. Make the target explicit in each published package so a missing or changed .npmrc cannot send it elsewhere:

{
  "name": "@acme/ui",
  "publishConfig": { "registry": "https://npm.pkg.github.com/" }
}

A package published to the wrong registry is a common cause of 404s for consumers, covered in Fixing npm publish E404 Not Found.

Using a single proxy registry instead

An alternative to routing each scope from the client is to put a proxy registry — Verdaccio, Artifactory, Nexus or CodeArtifact — in front of everything and point clients at one URL. The proxy decides where each package comes from, merges several upstreams behind one endpoint, and caches public packages. Clients then need a single registry= line and one credential.

Client-side scope routing versus a single proxy registry Compares configuring each scope in .npmrc with pointing all clients at one proxy registry on configuration, credentials, caching and control. client scope routing single proxy registry Client configuration one entry per scope one registry line Credentials on clients one per registry one Caching of public packages no yes Central policy (quarantine, blocklists) no yes Infrastructure to run none proxy service
Client routing needs no infrastructure; a proxy centralises routing, caching and policy at the cost of running it.

The two approaches combine well. Many organisations route all public and internal traffic through a proxy for caching and policy, and keep one or two direct scope mappings for partners whose registries the proxy cannot reach. Whatever you choose, keep the configuration in the repository, so the answer to "where does this package come from?" is visible in code review. Setting up a proxy is covered in Setting Up Verdaccio as a Private Proxy Registry and Publishing to AWS CodeArtifact.

Environment variables in CI

Each registry's token variable must be set in every CI job that installs from it. A frequent failure is a job that installs fine for public packages but fails on the first internal one with a 401, because its environment lacks GITHUB_PACKAGES_TOKEN. When an unset variable is expanded, npm sends an empty token, and the error names the registry host, which quickly identifies the missing secret. In GitHub Actions, actions/setup-node with registry-url and scope writes a scoped entry for you and reads NODE_AUTH_TOKEN; with several private registries, it is simpler to commit the multi-registry .npmrc shown above and set each variable explicitly in the job's env block.

Security: routing is a supply-chain control

Scope routing is also your main defence against dependency confusion. If @acme is not mapped, npm looks for @acme/ui on the public registry — where anyone who registers the acme organisation could publish a package with that name. Three measures close the gap:

  • Map every internal scope explicitly, in a committed project .npmrc, so no machine falls back to the public registry for internal names.
  • Claim your scope on the public registry (create the acme organisation), even if you never publish there, so nobody else can.
  • Never send internal tokens to public hosts. Because credentials match by host prefix, a token keyed to //registry.npmjs.org/ is sent with every public request; keep internal tokens keyed only to internal hosts.

The threat is covered in depth in Preventing Dependency Confusion Attacks.

Worked example: a new laptop that installed the wrong package

A developer's fresh laptop installs @acme/logger successfully, but the application crashes with an unfamiliar API. The project had no committed .npmrc; the scope mapping lived only in older laptops' ~/.npmrc. On the new machine, npm fetched @acme/logger from the public registry, where an unrelated person had published a package under an acme user scope. The team commits a project .npmrc with the scope mapping and environment-variable tokens, creates the acme organisation on the public registry (after resolving the name conflict with npm support), and adds a CI check that fails if any @acme package in the lockfile resolves from a host other than GitHub Packages.

Prevention and guardrails

  • Commit scope mappings in the project .npmrc; keep tokens in the environment.
  • Assert resolved hosts in CI with lockfile-lint --allowed-hosts, as in Configuring lockfile-lint for Supply-Chain Safety.
  • Set publishConfig.registry on every published package.
  • Own your scope names on the public registry.

Frequently Asked Questions

Can one scope use two registries? No. Each scope maps to exactly one registry. Use a registry that proxies others (Verdaccio, Artifactory, CodeArtifact) if you need to combine sources behind one URL.

Why does npm send my token to the wrong registry? Because a credential key matches by prefix. A key like //registry.example.com/:_authToken matches every path on that host. Make keys as specific as the registry URL.

Where should personal tokens live? In your user ~/.npmrc or shell environment, never in the project file. The project file references variables; your environment supplies them.

Do Docker builds need the same .npmrc? Yes. Copy the committed .npmrc into the build stage before installing, and pass tokens as BuildKit secrets rather than build arguments, so they never land in an image layer. Remove any .npmrc containing expanded tokens before the final stage.

How do I debug which credential npm used? Run the install with --loglevel=http; each request line shows the URL and whether authorization was sent (without printing the token). Mismatched hosts are usually obvious from the URL.

Related

Private Registries and Access Control