Configuring a Private npm Registry Proxy

Permalink to "Configuring a Private npm Registry Proxy"

Part of Registry & Package Manager Hardening, this page shows how to put a controlled chokepoint between your builds and the public registry, and how to configure npm so that every fetch goes through it with credentials that never touch the repository.

Quick Reference

Permalink to "Quick Reference"
Config key Example value Effect
registry https://npm.example.com/repository/npm-group/ Default registry for every unscoped package
@scope:registry https://npm.example.com/repository/npm-private/ Forces one scope to resolve at a named registry
//host/path/:_authToken ${NPM_TOKEN} Bearer token sent to URLs under that prefix
//host/path/:_auth base64 user:pass Legacy basic auth, for registries without tokens
always-auth true Sends credentials on tarball GETs too (npm 6, Yarn 1)
strict-ssl true (default) Rejects untrusted or mismatched TLS certificates
cafile /etc/ssl/internal-ca.pem PEM bundle trusted in addition to the system store
ignore-scripts true Blocks lifecycle scripts during install
before 2026-07-01 Resolves only versions published on or before a date

The token key and the registry key must agree on host and path prefix, including the trailing slash — a mismatch produces an anonymous request and a 401.

The mental model

Permalink to "The mental model"

A registry proxy is one hostname that every package manager in the organisation is pointed at. It answers metadata requests and tarball requests itself when it can, and reaches upstream to registry.npmjs.org only on a cache miss. Four properties fall out of that arrangement, and each is a security control rather than a convenience.

The first is immutable caching. Once the proxy has stored the tarball for [email protected], that byte sequence is what your builds get forever, even if the version is unpublished upstream, retagged, or replaced. The second is a single decision point: allow-lists, blocked names, and quarantine rules live on the proxy instead of being duplicated in every repository’s configuration. The third is an audit log — one access log line per fetch, with the requesting identity attached, which is the only practical way to answer “which builds pulled the compromised version, and when”. The fourth is dependency-confusion resistance: when an internal scope is served by a repository that is explicitly not backed by an upstream, a public package published under your internal name has nowhere to enter from.

The proxy does not verify who wrote a package. It moves the trust boundary from “whatever npmjs.org returns today” to “whatever we accepted into the cache”, which is a much smaller and more auditable surface, but the contents still need provenance checks such as Verifying Sigstore Provenance for npm Packages before promotion.

Private registry proxy topology Build clients fetch packages from a private registry proxy that holds an allow-list and quarantine, an immutable tarball cache and an audit log; the proxy contacts the public registry only on a cache miss, while the internal scope is published straight into the proxy and never fetched upstream. build clients npm ci, dev installs fetch private registry proxy allow-list + quarantine immutable tarball cache audit log of every fetch cache miss public registry registry.npmjs.org publish internal @acme scope never fetched upstream

Canonical example: a hardened .npmrc

Permalink to "Canonical example: a hardened .npmrc"

This is the complete file, committed at the repository root. It contains no secret: the token is a placeholder that npm expands from the process environment when it reads the file.

# .npmrc — committed. Contains no credentials.

# Everything unscoped resolves through the proxy's group endpoint.
registry=https://npm.example.com/repository/npm-group/

# Internal scopes are authoritative here and are never looked up upstream.
@acme:registry=https://npm.example.com/repository/npm-private/

# Credentials, expanded from the environment at read time.
//npm.example.com/repository/npm-group/:_authToken=${NPM_TOKEN}
//npm.example.com/repository/npm-private/:_authToken=${NPM_TOKEN}

# Send credentials on tarball requests too (needed by npm 6 and Yarn 1).
always-auth=true

# Verify TLS properly; trust the internal CA in addition to the system store.
strict-ssl=true
cafile=/etc/ssl/certs/acme-internal-ca.pem

# Reproducible resolution and no lifecycle scripts during install.
ignore-scripts=true
audit=false
fund=false

The ${NPM_TOKEN} form is expanded by npm itself, not by the shell, so the literal ${NPM_TOKEN} text is what lives in version control. If the variable is unset, npm sends the literal string as the token and the registry answers 401 — a loud failure, which is what you want. Setting ignore-scripts=true here is the client half of the control described in Disabling npm Install Scripts; the proxy cannot stop a postinstall script on its own.

The auth line is the part that people get wrong, so it is worth reading character by character.

Anatomy of an .npmrc auth line The line splits into a scheme-less URI prefix that must match the registry setting including its trailing slash, the _authToken configuration key whose value is sent as a bearer token, and an environment placeholder that npm expands when it reads the file. //npm.example.com/ :_authToken= ${NPM_TOKEN} URI prefix, no scheme trailing slash matters must match registry= auth config key value is sent as a bearer token expanded from the environment at read time, never committed

The prefix has no https: on it and it does include the path, because npm matches configured credentials against the request URL by longest prefix. If registry ends in /repository/npm-group/ but the token key stops at the bare host, some npm versions will still match and others will not; write both out in full and the ambiguity disappears.

Quarantine and promotion

Permalink to "Quarantine and promotion"

Immutable caching protects you from a version changing after you accepted it. It does nothing about a version that was malicious the moment it was published, and the window that matters is the first day or two of a compromised release. The answer is to treat “available upstream” and “installable here” as two different states, with an explicit transition between them.

A workable shape: the proxy holds a staging repository that is allowed to fetch upstream, and a serving repository that builds actually use. A new version lands in staging, waits out a minimum age, gets scanned and provenance-checked, and only then is copied into the serving repository. Nothing in the serving repository ever changes after promotion. If a version fails the checks it is blocked by name and version, and any install that asks for it fails at the proxy with a 403 rather than silently resolving to something else.

Quarantine to promotion state flow A newly published version is detected upstream, held in quarantine until it passes a minimum age hold plus scanning and provenance checks, then either promoted into the immutable serving cache or blocked so installs fail at the proxy. new version seen upstream detect min age hold quarantine held, not installable scan + provenance pass promoted to allow-list cached immutably by digest fail blocked by name+version install fails at the proxy

The client can approximate the age hold without any proxy support at all. npm’s before config resolves only versions that were published on or before a given moment, which turns “nothing younger than a week” into a one-line policy:

# Resolve as if today were 2026-07-29 — nothing published since is eligible.
npm install --before=2026-07-29

This is a blunt instrument, since it applies to the whole tree rather than to newly seen packages, but it is useful for reproducing an old resolution and for holding a release branch steady. A scheduled updater is the better long-term home for the waiting period, and reviewing the resulting diff is covered by Detecting Lockfile Tampering in Pull Requests.

Variants

Permalink to "Variants"

CI job with the token from a secret

Permalink to "CI job with the token from a secret"

The token is injected as an environment variable and never written to disk in plaintext. With GitHub Actions, actions/setup-node writes the registry line for you and expects NODE_AUTH_TOKEN:

name: build
on: [push]

jobs:
  install:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: 22
          registry-url: https://npm.example.com/repository/npm-group/
          scope: '@acme'
          cache: npm

      - name: Install from the proxy with a frozen lockfile
        run: npm ci --ignore-scripts
        env:
          NODE_AUTH_TOKEN: ${{ secrets.NPM_PROXY_TOKEN }}

On a runner without that action, write the file at job start and keep the placeholder literal by quoting the heredoc delimiter:

cat > .npmrc <<'EOF'
registry=https://npm.example.com/repository/npm-group/
@acme:registry=https://npm.example.com/repository/npm-private/
//npm.example.com/repository/npm-group/:_authToken=${NPM_TOKEN}
//npm.example.com/repository/npm-private/:_authToken=${NPM_TOKEN}
EOF

# NPM_TOKEN comes from the CI secret store, not from the repository.
npm ci --ignore-scripts

Quoting 'EOF' stops the shell from substituting the variable, so the file on disk still contains only a placeholder; npm resolves it at read time. A token pasted into a committed .npmrc, or echoed into a log line, is a registry-wide credential leak — treat it exactly as you would a cloud access key. Pair this with a pinned package manager version, as described in Enforcing Package Manager Versions with Corepack, so the client reading this configuration is also fixed.

Internal CA instead of disabling TLS verification

Permalink to "Internal CA instead of disabling TLS verification"

When the proxy terminates TLS with a certificate from an internal authority, npm reports SELF_SIGNED_CERT_IN_CHAIN or UNABLE_TO_VERIFY_LEAF_SIGNATURE. The wrong fix, and the one every forum answer suggests, is strict-ssl=false, which turns the chokepoint into an unauthenticated man-in-the-middle target. Trust the CA instead:

# Per project, via .npmrc
npm config set cafile /etc/ssl/certs/acme-internal-ca.pem

# Or for every Node process on the runner, including npm's fetch layer
export NODE_EXTRA_CA_CERTS=/etc/ssl/certs/acme-internal-ca.pem

cafile replaces npm’s own certificate authority list rather than adding to it, so the PEM must contain the full chain the proxy presents. NODE_EXTRA_CA_CERTS is additive and usually the lower-risk option on a shared runner image.

Other package managers on the same proxy

Permalink to "Other package managers on the same proxy"

pnpm reads .npmrc with the same key names, so the file above works unchanged; pnpm install --frozen-lockfile is the equivalent install command. Yarn Berry keeps its own configuration in .yarnrc.yml with npmRegistryServer, npmScopes and npmAuthToken, so a mixed estate needs both files kept in sync. The differences between the frozen-install commands are laid out in npm ci vs pnpm --frozen-lockfile vs yarn --immutable.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • A merged group endpoint can reintroduce dependency confusion. Many proxies let you expose an internal repository and an upstream mirror through one URL. If a name exists in both, the merge order decides the winner, and a higher public version can beat your internal one. Keep internal scopes on their own endpoint referenced by @scope:registry, and configure that repository with no upstream at all.

  • The lockfile records the proxy host. Every resolved URL in package-lock.json will point at npm.example.com. The integrity field is a hash of the tarball bytes and stays valid regardless of host, but a contributor outside the network cannot install, and migrating to a new proxy hostname rewrites thousands of lines. Decide deliberately whether external contributors are in scope before rolling this out.

  • A stale mirror is a silent failure mode. If upstream synchronisation breaks, installs keep succeeding from cache while security patches never arrive. Nothing in the build will complain. Monitor the age of the newest upstream fetch and alert on it, and keep advisory scanning on its own path — see Triaging npm audit Findings for what to do with the output.

  • An unauthenticated proxy is a cache-poisoning target. If anyone on the network can publish to the internal repository or force a cache fill, the chokepoint becomes the fastest way to reach every build in the company at once. Require authentication for writes, restrict publish rights to CI identities, and log every write with the identity attached.

  • One chokepoint is one outage. A proxy that all builds depend on is a single point of failure for every deployment, including the emergency ones. Run it with real availability engineering, cache the npm store in CI so a short outage is survivable, and write down the break-glass procedure before you need it.

Verification Steps

Permalink to "Verification Steps"

1. Confirm which registry the client will use

Permalink to "1. Confirm which registry the client will use"
npm config get registry
npm config get @acme:registry

Expected output is the two proxy URLs from .npmrc, each with its trailing slash. If you see https://registry.npmjs.org/, npm is reading a different configuration file — npm config list -l | head -n 20 prints the file paths in precedence order.

2. Confirm the proxy answers and the token is accepted

Permalink to "2. Confirm the proxy answers and the token is accepted"
npm ping --registry=https://npm.example.com/repository/npm-group/

A successful run prints PONG along with the registry URL. A 401 Unauthorized means the _authToken prefix does not match the request URL; an ENOTFOUND or ECONNREFUSED means the client never reached the proxy at all.

3. Confirm packages actually resolve through the proxy

Permalink to "3. Confirm packages actually resolve through the proxy"
npm view lodash dist.tarball

Expected output is a URL on npm.example.com, not on registry.npmjs.org. Then check that installs agree:

grep -m1 '"resolved"' package-lock.json

4. Confirm the internal scope has no upstream fallback

Permalink to "4. Confirm the internal scope has no upstream fallback"
npm view @acme/internal-only-name version --registry=https://registry.npmjs.org/

Expected output is npm error code E404 — the name must not exist publicly. If it resolves, someone has published your internal name upstream and the per-scope registry line is the only thing standing between that package and your builds.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Does a registry proxy stop dependency confusion by itself?

Only if you configure it to. A proxy that merges an internal repository and the public registry into one endpoint will still serve a public package that shares your internal name if the public version sorts higher. The protection comes from a per-scope registry line in .npmrc plus a proxy rule that refuses to look upstream for names in that scope.

Should the lockfile contain the proxy host in its resolved URLs?

It will, and that is usually fine inside one organisation. The integrity field stays a hash of the tarball bytes and is host independent, so verification still works. Problems appear when the lockfile is shared with people who cannot reach the proxy, or when the proxy host changes and every resolved URL churns in the diff.

Does npm audit still work through a private proxy?

Not always. Auditing uses a bulk advisory endpoint that many proxy products either do not implement or forward selectively, so npm audit can fail or return nothing. Run audits against the public registry explicitly with the registry flag, or run them from a job that is allowed direct egress, and keep the install path pointed at the proxy.

What happens to builds when the proxy is down?

Every install that needs a network fetch fails, because the client has no fallback registry configured. That is the price of a single chokepoint. Mitigate it with a warm shared cache in CI, a documented break-glass registry value that a release engineer can set deliberately, and monitoring on the proxy that pages before a release window.

Is always-auth still required?

Modern npm attaches a configured token to any request whose URL starts with the matching prefix, including tarball downloads, so the option is unnecessary and newer npm versions removed it. It still matters for npm 6 era clients and for Yarn 1, where tarball fetches would otherwise go out unauthenticated and be rejected by a proxy that requires a login.

Permalink to "Related"

Related Articles

Disabling npm Install Scripts
Enforcing Package Manager Versions with Corepack
Registry & Package Manager Hardening Supply Chain Auditing & Depend…