Private Registry Configuration for npm and pnpm
Misconfigured private registries enable dependency confusion attacks on your supply chain.

A misconfigured private registry is a supply chain vulnerability, not a workflow annoyance. The central risk is dependency confusion: when a private package name lacks a scope pin or its registry is not explicitly declared, the package manager resolves against the public registry. Anyone can claim that name there. Between the configuration files and the lockfile sits a structural gap that no patch will close, because no protocol-level mechanism links a private registry namespace to its public counterpart.
The threat is no longer theoretical. Microsoft Threat Intelligence documented an npm supply chain compromise in April 2026 involving axios, a package with millions of weekly downloads. A foundational package, one that development teams install without a second thought, became an active target. The attack surface also extends well beyond direct dependencies. Transitive dependencies compound the exposure, and worm payloads have been documented arriving through declared transitive dependencies rather than the package a developer explicitly chose. A team that routes its own packages carefully but lets its tree resolve unscoped names against the public registry has only protected the front door. The rest of the building remains open.
How npm resolves registry URLs and why config file precedence determines which registry actually gets called
npm defaults to the public registry and falls back to it silently whenever a more specific rule is absent. Understanding what triggers that fallback requires understanding the resolution order. The registry URL used for a package is determined by the package's scope, and if no scope is specified, the default registry config parameter applies. The question, then, is where that parameter's value comes from.
Config precedence runs from highest to lowest: command-line options, environment variables, project-level .npmrc, user-level ~/.npmrc, global config, then built-in defaults. A setting at a lower level is silently overridden by a higher one. The .npmrc file itself exists at three levels. The global file sits at $npm_config_prefix/etc/npmrc and applies to all users on the machine. The user-level file lives at ~/.npmrc on macOS and Linux or %USERPROFILE%\.npmrc on Windows, and applies to the current account. The project file sits at ./.npmrc in the project root, applies only to that project, and overrides both global and user-level settings. Auth-related keys must be scoped to a specific registry in any .npmrc, which matters later when credentials enter the picture.
The lockfile introduces a subtler complication. npm carries registry URLs in the lockfile, and the semantics differ depending on which URL appears. When the default registry is used in a package-lock, it carries the special meaning of "the currently configured registry." When a custom registry URL is written into the lockfile, packages will be installed from that registry even after the default is changed. A lockfile can therefore anchor a team to a registry that no longer exists or has been rotated, and nothing in the configuration will warn them. For publishing, the publishConfig field in package.json offers one safeguard: setting "publishConfig": {"registry": "..."} prevents accidental publication to the public registry even when a developer runs npm publish without checking where it will go.
Configuring scoped package routing: the @scope:registry key and why unscoped private packages remain exposed
The correct pattern for routing a private scope to a private registry is a single .npmrc line: @yourorg:registry=. All packages under @yourorg resolve to the private registry, but public packages remain unaffected. This is the configuration that makes dependency confusion structurally impossible for scoped packages, because the package name and the registry route are bound together.
Setting the global registry key to the private registry instead of using a scoped key only works safely if the private registry proxies the public registry. Without proxying, every unscoped public package becomes a failed resolution. The scoped key is the more precise instrument, and the limitation of unscoped names explains why it matters. An unscoped private package name has no namespace protection: any name not prefixed with a scope can be claimed on the public registry by anyone, which makes dependency confusion trivially exploitable. The fix is scope-pinning. All internal packages must use scoped names like @yourorg/package-name, and that scope must route exclusively to the private registry. This is a naming convention decision you make before packages are published, not something you retrofit easily afterward, because renaming packages breaks every existing dependency declaration.
The ecosystem reinforces this requirement. GitHub Packages only supports scoped packages, while self-hosted options such as Verdaccio and registry services like JFrog Artifactory support both scoped and unscoped packages. The publishConfig field in package.json closes the publishing side of the loop, but the installation side is governed entirely by the .npmrc scope mapping.
Authentication token configuration: what goes where and why tokens must never be committed to the repository
Registry routing fails without credentials, and credentials in the wrong file create a secret-exfiltration vulnerability. Authentication tokens are registry-scoped in .npmrc using the URL-keyed syntax: //private-registry.your-org.com/:_authToken=TOKEN. The double-slash prefix and trailing slash are required for the pattern to match correctly, and omitting either means the token is ignored.
Tokens must never be written as literal values in a project-level .npmrc committed to the repository. The correct locations are the user-level ~/.npmrc, or in CI, an environment variable such as NPM_TOKEN referenced from a user-level or trusted config file. The committed project .npmrc should contain only registry URL routing, meaning the @scope:registry line, and credentials stay out of version control entirely. In CI, the recommended pattern is to inject the token via an environment variable and write it to the user-level config in a pipeline step before installation runs. This keeps the secret out of the repository while still making it available to the install command.
Interactive login handles the placement automatically. If a team runs npm login --registry=... --scope=@yourorg, npm writes the resulting token to the user-level ~/.npmrc. That is safe, but it is a per-developer, per-machine action, not a shared team config. Each machine that needs to install private packages must have its own credentials configured, which is why the CI injection pattern exists as the team-scale counterpart.
Where pnpm diverges from npm on registry and authentication configuration
pnpm separates concerns more explicitly than npm. Registry and non-auth settings live in pnpm-workspace.yaml, while authentication settings are read exclusively from .npmrc-style files. Teams migrating from npm must understand both halves of this split, because a setting placed in the wrong file is silently ineffective.
For authentication, pnpm defines a clear file priority order, from highest to lowest. The workspace root's .npmrc is project-level auth and should be listed in .gitignore. The <pnpm config>/auth.ini file is the legacy user-level auth file: pnpm 11 wrote login tokens here, but pnpm 12.1 and newer writes to the global config.yaml instead, while continuing to read auth.ini. The ~/.npmrc file is read as a fallback for easier migration from npm. The global config.yaml holds pnpm's structured _auth setting, the target for login tokens in current versions. The <pnpm config> directory location varies by OS: $XDG_CONFIG_HOME/pnpm/ if that variable is set, ~/AppData/Local/pnpm/config/ on Windows, ~/Library/Preferences/pnpm/ on macOS, or ~/.config/pnpm/ on Linux. This matters when diagnosing why a token exists on one machine but not another.
pnpm also offers structured alternatives that npm lacks. registries and namedRegistries in pnpm-workspace.yaml provide the pnpm-native way to declare multiple registries, replacing per-scope .npmrc lines and simplifying multi-registry monorepo configuration. The packageConfigs feature, added in v11.0.0, allows project-specific configuration for individual workspace packages directly in pnpm-workspace.yaml, replacing workspace project-specific .npmrc files. The direction of pnpm's evolution is toward consolidating non-secret configuration into a single structured file, while pushing secrets entirely out of the repository.
The pnpm-specific security rule on environment variable expansion and what it means for CI token injection
pnpm imposes a restriction on environment variable expansion that trips teams migrating from npm, and the motivation is precise. The project .npmrc is checked out together with the repository, so expanding environment variables there would allow a malicious repository to exfiltrate CI secrets to an attacker-controlled registry during installation. This is the vulnerability behind the pnpm advisory, and pnpm fixed it in v11.5.3.
Since that version, environment variables are not expanded in the project-level .npmrc for registry and proxy URLs, URL-scoped keys starting with //, and credential values including _authToken, _auth, _password, username, tokenHelper, cert, and key. A setting containing a ${...} placeholder in these positions is silently ignored, and pnpm prints a warning. The restriction applies only to the committed project file. Environment variables are still expanded in user-level auth files, meaning auth.ini and ~/.npmrc, because those files are not shared through version control.
CI teams have three correct alternatives. The first writes the token to the user-level auth file in a CI step before installing: pnpm config set //registry.npmjs.org/:_authToken "$NPM_TOKEN". The pnpm config set command writes to the global location by default, not to the project .npmrc. The second, available since v11.6, passes the credential directly as an environment variable using the pnpm_config_//... naming convention, avoiding any .npmrc file entirely: env "pnpm_config_//registry.npmjs.org/:_authToken=$NPM_TOKEN" pnpm install. The env utility is necessary because the variable name contains /, :, and . characters that shell assignment syntax rejects. The third option keeps the ${NPM_TOKEN} placeholder line but places it in the user-level ~/.npmrc instead of the project file. For teams that cannot easily modify each CI pipeline, pnpm offers a fourth escape hatch: setting PNPM_CONFIG_NPMRC_AUTH_FILE=.npmrc in the CI environment. This makes pnpm treat the project .npmrc as the user-level auth file, but it disables the protection entirely and should only be used in environments that exclusively build trusted repositories.
Choosing a private registry host: what the major options actually offer and where each fits
The registry host choice determines what scanning, caching, and access control capabilities are available. The configuration mechanics apply to all hosts, but the operational guarantees differ substantially, and teams should choose based on what they can actually operate rather than marketing claims. Verdaccio, self-hosted and open source, offers full npm compatibility, a built-in proxy to the public npm registry, and no licensing cost. Infrastructure management is entirely the team's responsibility: servers, storage, backups, SSL, and security patching. There is no built-in high availability or enterprise-grade RBAC and audit logging. It is the strongest fit for small teams with DevOps capacity who need full control or an air-gapped environment, and a poor fit for teams that lack the bandwidth to run another service in production.
GitHub Packages integrates with GitHub repositories and Actions, with no additional accounts to manage. It supports only scoped packages, so unscoped private packages cannot be published there, which reinforces the scope-pinning requirement discussed earlier. The service is tied to the GitHub ecosystem, and storage and transfer limits apply on all tiers, with overage fees for storage and data transfer on any plan that exceeds its included quota. It fits teams already on GitHub who only need scoped packages and want minimal configuration overhead.
JFrog Artifactory supports over 60 package formats, virtual repositories for aggregation, and mature enterprise features. The trade-offs are consumption-based pricing, which can create unpredictable bills, and complex setup and administration. Support tiers matter for teams choosing it: the Pro plan carries community support, while around-the-clock SLA support starts at the Pro X tier for self-managed deployments or the Enterprise X tier for cloud. The Pro base at list price is a set monthly fee plus consumption charges above the included storage threshold.
What a correctly configured setup looks like end to end, and the failure modes that produce silent public-registry fallback
A correctly configured npm setup for a private scoped registry has three coordinated parts in the committed project .npmrc: the scope-to-registry mapping, no credential values, and no environment variable placeholders. Credentials live in the user-level or CI-injected layer only. A correctly configured pnpm monorepo setup puts registry URL declarations in pnpm-workspace.yaml under registries or namedRegistries, keeps the committed .npmrc free of credential placeholders, and injects tokens via pnpm config set in CI or via the pnpm_config_//... environment variable pattern.
The failure modes that produce silent public-registry fallback are specific and avoidable. A missing or mismatched scope means the package is named @yourorg/utils but the .npmrc routes @acme, so npm resolves against the public registry and either finds nothing or finds an attacker-planted package. A credential in the wrong file is subtler: a token placeholder in a committed project .npmrc is silently ignored after pnpm v11.5.3, and pnpm prints a warning but proceeds against whatever registry answers next. Lockfile anchoring means a lockfile written while the default public registry was active continues routing to the public registry even after the team adds a private registry config, because the lockfile's recorded URL overrides the new config for already resolved packages. A scope set without a proxy still fetches all unscoped dependencies directly from the public registry with no scanning or caching, which prevents dependency confusion for scoped packages but leaves the transitive dependency graph exposed.
Two additional practices reduce the remaining exposure. Disabling post-install scripts during installation removes a frequently exploited code-execution vector, since many supply chain attacks targeting npm, including recent campaigns, have used postinstall hooks to execute arbitrary code during npm install. Using npm ci or pnpm install --frozen-lockfile in CI rather than npm install enforces the lockfile and prevents opportunistic version resolution against the wrong registry. The layers combine into a coherent defense: scoped routing, a caching proxy, locked environments, and locked dependency resolution, each only as strong as the weakest configuration decision. Teams that want the environment itself, including registry config, token injection method, and pinned package versions, to be a portable, auditable artifact rather than a per-machine assumption should look at environment managers that encode all of it in a versioned manifest.


