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.
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:
- 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. - Trailing slashes matter. Keep them consistent between the
registryvalue and the credential key. - Tokens come from environment variables, so the file can be committed safely. Developers set them in their shell; CI sets them from secrets.
- One registry per scope. A scope cannot be split across registries; if some
@acmepackages are public and some private, publish them all to one registry that can serve both, or use different scopes such as@acmefor public and@acme-internalfor 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.
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.
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
acmeorganisation), 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.registryon 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 covers private registry options and access.
- Configuring a Project .npmrc for Consistent Installs explains
.npmrclayering. - Scoping GitHub Packages to a Single Organization sets up the GitHub side of this configuration.
- Fixing npm 401 Unauthorized on a Private Registry fixes credential matching failures.