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.
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.
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.
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"
}
}
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.
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 }}
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. |
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.
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.