Private Registries and Access Control
Publishing internal packages to the public npm registry leaks your code and namespace; publishing them with no access controls invites supply-chain compromise. This guide covers running and consuming a private registry — GitHub Packages, a self-hosted Verdaccio, or scoped npm org packages — with correct authentication, and it extends the Package Publishing & Release Engineering section toward internal distribution.
Concept overview and where it fits
A private registry solves two problems at once: it keeps proprietary packages off the public index, and it gives you a controlled resolution path so installs cannot silently pull a same-named package from elsewhere. Most teams route scoped packages (@acme/*) to a private registry while everything else resolves from the public npm registry, using per-scope registry configuration. The scoping mechanics build directly on the npm registry publishing workflow.
The decision to run a private registry is really two decisions bundled together: where proprietary packages live, and how the resolution path is controlled. Keeping internal packages off the public index is the obvious half, but the subtler and often more valuable half is that a configured private registry gives you a controlled resolution path, so an install cannot silently pull a same-named package from an unexpected source. That control is what closes the dependency-confusion attack, where an attacker publishes a public package matching your internal name and hopes a misconfigured client resolves theirs instead of yours.
Most teams do not route everything through the private host — they route only their own scope. A per-scope registry mapping sends @acme/* to the private registry while everything else continues to resolve from the public one, which keeps the private host's load and trust surface confined to your namespace and lets public dependencies resolve normally. The three common hosts — GitHub Packages for zero-infrastructure integration with an existing GitHub org, Verdaccio for a self-hosted caching proxy you fully control, and an npm organization for scoped private packages on the canonical registry — differ in operations and control but present the same per-scope client configuration.
It helps to separate three concerns that a private registry addresses at once: confidentiality (proprietary code stays off the public index), integrity (what installs is what you published, verified by hashes), and authorization (only the right identities can read or publish). A hosted option handles all three with minimal setup; a self-hosted proxy trades operational ownership for control over each. Deciding which concerns matter most to your team — strict data residency, availability during upstream outages, or simply keeping a few packages private — points directly at the right host.
Initialization and per-scope configuration
Configure resolution per scope in .npmrc so only your namespace hits the private host. Keep the auth token in an environment variable, never committed.
# .npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# everything else still resolves from the public registry
registry=https://registry.npmjs.org/
The ${NODE_AUTH_TOKEN} reference is expanded from the environment at install time, so the token lives in your secret store, not the repo.
The .npmrc is where resolution and authentication are wired, and the golden rule is that the auth token is referenced from the environment, never inlined. A committed .npmrc should contain the scope-to-registry mapping and an auth line that reads the token from an environment variable, so the file is safe to check in while the actual secret lives in a secret store and is injected at install time. Inlining a token in a committed file is one of the most common and damaging leaks, because it publishes a live credential to everyone with repository access and to the git history forever.
Host matching is exact, which is the source of most authentication confusion. npm matches the auth line against the registry host precisely — including the trailing slash and any port — so a mapping to a GitHub Packages host requires an auth line keyed to exactly that host. A mismatch produces an anonymous request and a 401 that looks like a token problem but is really a host-binding problem. Verifying with a whoami call against the specific registry early confirms the binding before an install fails halfway through.
Per-scope configuration is also what lets a monorepo consume a mix of private and public packages without friction. A single .npmrc maps your organization scope to the private host and leaves the default registry public, so one install resolves internal packages from the private registry and their public dependencies from the public one, each verified against the lockfile. There is no need to route everything through one host, which keeps the private registry's trust surface and load confined to exactly the packages that belong on it.
Architecture: how a registry resolves a package
A registry is content-addressed metadata plus tarballs: the client asks for a package version, the registry returns the manifest and a tarball URL, and the client verifies the integrity hash against the lockfile. A private registry either stores tarballs itself (Verdaccio, GitHub Packages) or proxies the public registry for anything it does not host, so a single configured host can serve both your packages and their public dependencies.
A registry is, at its core, content-addressed metadata plus tarballs served over a uniform protocol: the client requests a package version, the registry returns a manifest with a tarball URL, the client downloads the tarball and verifies its integrity hash against the lockfile. Because the protocol is identical whether a package is yours or public, a single host can serve both — which is exactly how a proxy registry works, answering for packages it hosts and fetching-and-caching anything it does not from an upstream.
That caching behavior is a quietly valuable availability property. Once a proxy has cached a public tarball, installs that need it succeed even when the upstream registry is unreachable or a version has been unpublished, so a transient public-registry outage does not fail your CI. The integrity verification still applies to proxied tarballs, so caching does not weaken the guarantee that what you install matches what the lockfile pinned — it only removes the hard dependency on the upstream being available at install time.
The manifest a registry returns is more than a tarball pointer — it carries the dependency metadata the resolver uses to build the graph, the integrity hashes that verify each download, and any deprecation or provenance information attached to the version. A private registry serves the same shape of metadata for your packages as the public one does for its, which is why standard tooling works unchanged against it: the client does not know or care that a package is private, only that the configured host answered with a valid manifest and a hash that matches.
Execution strategy: publish access levels
Set publish-time access explicitly so a scoped package does not accidentally go public. For npm orgs, --access restricted keeps it private; for GitHub Packages, repository visibility governs it.
// package.json
{
"name": "@acme/ui",
"version": "2.3.0",
"publishConfig": {
"registry": "https://npm.pkg.github.com",
"access": "restricted"
}
}
Access level is the setting that decides whether a scoped package is visible to the world, and getting its default wrong is how proprietary code leaks. Scoped packages can be published restricted (private) or public, and the safe posture for internal packages is to make private the explicit default: set the publish access to restricted and keep the package private during development so a stray publish cannot push internal code to the public index. Pinning the publish registry to the private host means the publish target is fixed regardless of a developer's default registry.
Publishing from CI rather than a laptop is the other half of a safe publish path. A CI job using a short-lived, scoped token — GitHub's built-in token with package-write permission, or an npm granular token from a secret store — keeps the publishing credential out of personal environments and scoped to exactly what the job needs. The job sets the registry, installs with a frozen lockfile and ignored scripts, and publishes, so the release is reproducible and the credential expires with the run.
Scoped and unscoped packages behave differently, and the difference matters for both privacy and confusion resistance. An unscoped name lives in the shared global namespace where anyone can register a collision, while a scoped name (@org/pkg) is namespaced to your organization and can be routed wholesale to a private host. For internal packages, scoping is not a stylistic choice but a security control: it is what makes a per-scope private mapping possible and what removes the ambiguity dependency confusion exploits.
Pinning the publish target in the manifest is a small guard with outsized value. Setting the publish registry in publishConfig means a publish goes to the intended private host regardless of whatever default registry a developer's environment happens to have configured, so an internal package cannot be pushed to the public index by an environment misconfiguration. Combined with keeping the package private during development, it makes an accidental public disclosure — which is often irreversible — structurally difficult rather than merely discouraged.
Security and isolation
Access control is the whole point, so treat tokens as the crown jewels. Use short-lived, scoped tokens (GitHub's GITHUB_TOKEN, npm granular access tokens) rather than a personal token with publish rights across everything, require 2FA for human publishes, and prefer OIDC-based provenance so a published tarball is cryptographically tied to the CI build that produced it. Lock the resolution path with a per-scope registry so a typosquat cannot be resolved from an unexpected host.
Access control is the entire purpose of a private registry, so its credentials deserve the same rigor as production secrets. Prefer short-lived, narrowly-scoped tokens over long-lived personal ones, require two-factor authentication for human publishes, and pin the per-scope registry so a typosquat cannot resolve from an unexpected host. The strongest posture removes the standing secret altogether with OIDC-based publishing, where CI exchanges a verified identity for short-lived publish rights and there is no token to leak or rotate.
Provenance extends the trust chain to consumers of your private packages just as it does for public ones. Publishing with an OIDC-signed attestation ties each tarball to the source commit and build that produced it, so an internal consumer can verify a package came from your pipeline and was not tampered with — a defense against an insider or a compromised credential publishing a malicious version under a trusted internal name. Combined with a pinned resolution path and scoped tokens, provenance turns a private registry from merely hidden into genuinely verifiable.
Isolating the resolution path is as important as protecting credentials, because an attacker who cannot steal your token may still try to trick your client into resolving their package. Pinning each scope to a specific host, and asserting with lockfile-lint that every lockfile entry resolves from an allowed host, means there is no ambiguous case a confusion attack can exploit. Combined with --ignore-scripts to neutralize install-time code, the install path becomes something you can reason about: known hosts, verified hashes, no arbitrary execution.
CI/CD integration
Publish from CI with a token minted for the job, never a developer's laptop credential:
jobs:
publish:
runs-on: ubuntu-latest
permissions:
contents: read
packages: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
registry-url: https://npm.pkg.github.com
- run: pnpm install --frozen-lockfile --ignore-scripts
- run: pnpm publish --no-git-checks
env:
NODE_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
The registry configuration a CI job needs is minimal but exact: map the scope, inject the token, and set the registry on the Node setup step so both installs and publishes target the right host. For a consuming job, grant package-read permission and use the built-in token so it can resolve org-scoped dependencies without a personal credential; for a publishing job, grant package-write and the id-token permission if you publish provenance. Keeping these permissions explicit and minimal follows the least-privilege principle and documents exactly what each job is allowed to do.
Verifying authentication as an explicit early step is worth the few seconds it costs. A dedicated check that runs a whoami against the private host before any install converts a confusing mid-install 401 into a clear, up-front failure that names the registry and the identity, so an engineer immediately knows whether the problem is a missing token, wrong scopes, or a host-binding mismatch. It also documents, for the next person, exactly which credential the pipeline expects and where it comes from.
Common Pitfalls and Remediation
| Mistake | Impact | Remediation |
|---|---|---|
Token committed in .npmrc |
Credential leak, unauthorized publish | Reference ${NODE_AUTH_TOKEN}; store the token in secrets. |
| No per-scope registry | Typosquat resolves from a wrong host | Pin @scope:registry so only your namespace hits the private host. |
| Publishing scoped packages as public by default | Proprietary code leaks | Set publishConfig.access to restricted. |
| Long-lived personal publish token | Broad blast radius if leaked | Use short-lived, scoped CI tokens with 2FA. |
The recurring theme across private-registry failures is a mismatch between where the client thinks a package lives and where it actually lives. A 404 usually means the scope is unmapped, so npm asked the public registry, which does not have your private package; a 401 means the request reached the private host but carried no valid credential; a 403 means it authenticated but lacks permission. Reading the status code first — routing, then authentication, then authorization — takes you to the right fix immediately instead of blindly regenerating tokens.
The other recurring failure is a credential that works locally but fails in CI, which is almost always an unset environment variable rather than a bad token. The committed .npmrc references the token from the environment, and if the CI job does not inject that variable the reference expands to empty and the registry returns 401. The fix is to confirm the secret is present in the job and named exactly as the .npmrc expects, verified by an early whoami step that fails fast and names the registry rather than surfacing as a confusing mid-install error.
Defending against dependency-confusion attacks
Dependency confusion is the attack a private registry most directly defends against, and understanding it explains why per-scope routing matters so much. The attack works when an internal package name — say an unscoped acme-utils — also exists, or is registered by an attacker, on the public registry, and a client is configured such that it might resolve the public one. If resolution is ambiguous, a routine install can pull the attacker's package, executing their code with your credentials.
The structural defense is to make resolution unambiguous. Scope internal packages under an organization scope and map that scope explicitly to the private registry, so @acme/* can only ever resolve from the host you control. Avoid unscoped internal names entirely, since they share the global namespace with every public package. Pair the scope mapping with lockfile-lint to assert that every resolved entry comes from an allowed host, and the attack surface closes: there is no configuration under which an internal name resolves from an untrusted source, because the client is told exactly where your namespace lives.
Choosing and operating the right registry
The three hosts suit different teams, and choosing well saves ongoing pain. GitHub Packages is the low-friction default when your code already lives on GitHub: no infrastructure to run, org membership governs access, and the built-in token handles CI auth. An npm organization is the choice when you want scoped private packages on the canonical registry with granular tokens and the familiar npm workflow. Verdaccio is for teams that need full control — an air-gapped or strictly-audited resolution path, or public-registry caching for availability — and are willing to own its uptime, TLS, and backups.
Whichever you pick, the operational essentials are the same: authentication that is not open self-registration, TLS so tokens are never sent in the clear, and — for a self-hosted host — backups of the storage that holds the only copy of your private tarballs. A common and painful mistake with a self-hosted registry is an ephemeral storage volume that a restart wipes, taking every private package with it. Treat a shared registry as infrastructure with the same rigor as any service holding code you cannot re-download from anywhere else.
Granular tokens and rotating credentials safely
A publish credential is a high-value secret, so its scope and lifetime are the levers that limit damage when it leaks — and credentials do leak, through logs, laptops, and history. A classic token with organization-wide rights can republish or deprecate everything you own; a granular token scoped to one package can touch only that package. Prefer the narrowest scope a job needs, set an expiry so a forgotten token cannot live forever, and read it from a secret store rather than embedding it in a pipeline definition.
Rotation is where teams get hurt, because it is a two-sided change with a fragile ordering. Revoke the registry side before the secret store is updated and every release in the gap fails for lack of a valid credential. The safe order is always create, deploy, verify, then revoke: mint the replacement, update the CI secret without revoking the old token, prove the new one with a dry-run publish on the real pipeline, and only then revoke the old. Alerting on token expiry ahead of time turns rotation from an incident into scheduled maintenance — and moving to OIDC removes the standing secret entirely, so there is nothing to rotate at all.
Package visibility versus repository access
A frequent source of confusion with GitHub Packages is conflating two different permission axes: whether an identity can see the source repository, and whether it can install the package. They are separate. A package can be private to the org, visible to selected repositories, or public, and a consumer resolves it only if their identity sits on the right side of that setting — regardless of their access to the source. A token that can read your code may still be unable to read your package.
For internal packages the usual goal is installable by any repository in the organization but invisible outside it, which means setting the package visibility to the org, granting the consuming repositories access, and giving their workflows package-read permission on the built-in token. External collaborators need an explicit grant and a token carrying the read scope. Framing every access problem as two questions — is the scope routed to the private host, and is this identity allowed to see the package — turns a class of baffling 404s and 401s into a quick, mechanical diagnosis.
Frequently Asked Questions
GitHub Packages, Verdaccio, or an npm org — which should I choose?
Choose GitHub Packages if your code already lives on GitHub and you want zero infrastructure; Verdaccio if you want a self-hosted proxy with full control; an npm org if you want scoped private packages on the canonical registry with granular tokens.
Can I mix private and public dependencies?
Yes. Route only your scope (@acme:registry=...) to the private host and leave the default registry public, so private packages and their public dependencies both resolve from one install.
Why use per-scope registry config instead of a global one?
A global registry override sends every request — including public dependencies — to one host, which is slower and a larger trust surface. Per-scope config keeps the blast radius to your namespace.
How do I stop an internal package from being published publicly by accident?
Set publishConfig.access to restricted and private: true during development, and gate publishing behind a CI job that uses a scoped token rather than a developer's global credential.
What actually stops a dependency-confusion attack?
Unambiguous resolution. Scope internal packages under your org scope, map that scope explicitly to the private registry so it can only resolve from the host you control, avoid unscoped internal names, and add lockfile-lint to assert every entry resolves from an allowed host.
Why does the same install 404 in one environment and work in another?
The environment where it 404s has not mapped your scope to the private registry, so npm queries the public one, which lacks your package. Ensure the scope-to-registry mapping and the matching auth line are present in every environment's .npmrc.
Do I need a private registry if I only have a few internal packages?
If they are genuinely private, yes — the public registry has no private-package option without a paid plan, and routing them privately also closes dependency confusion. For a handful of packages, GitHub Packages or an npm org needs almost no setup.
Why scope internal packages instead of using unscoped names?
A scope namespaces packages to your organization and lets you route the whole scope to a private host, which removes the ambiguity dependency confusion exploits. Unscoped names share the global namespace where anyone can register a colliding public package.
What's the simplest way to host private packages?
If your code is on GitHub, GitHub Packages needs almost no setup — map your org scope and use the built-in token. Choose an npm organization for scoped private packages on the canonical registry, or self-host Verdaccio when you need full control or public-registry caching. Scope internal packages either way to close dependency confusion.
Related
- npm Registry Publishing Workflows — the publish lifecycle and scoping this guide builds on.
- Publishing Scoped Packages to npm — the scoped-package publish details for org registries.
- Supply-Chain Security Hardening — the token, provenance and resolution defenses that protect a registry.
- Lockfile Management Strategies — integrity verification of what a registry returns.