Fixing npm 401 Unauthorized on a Private Registry
An install or publish against your private registry fails with 401 Unauthorized even though you 'logged in'. This page shows the exact error, the token and .npmrc mistakes that cause it, and how to authenticate correctly per scope.
Exact symptoms and error messages
npm error code E401
npm error 401 Unauthorized - GET https://npm.pkg.github.com/@acme%2fui
npm error Unable to authenticate, need: Basic realm="GitHub Package Registry"
It appears on npm install for a scoped package or on npm publish to the private host.
Root cause analysis
A 401 means the request reached the registry but carried no valid token for that host. The usual causes are an auth line keyed to the wrong host, a token without the required scope (read:packages / write:packages), or an unexpanded ${NODE_AUTH_TOKEN} because the variable is not set in the environment. Per-host auth is exactly the resolution model described in Private Registries and Access Control.
A 401 is specifically an authentication failure — the request reached the registry but carried no credential it accepted — which distinguishes it from a 404 (the scope was never mapped, so npm asked the wrong registry) and a 403 (authenticated but not authorized). Knowing which of the three you have narrows the fix immediately: 401 means the token or its host binding is wrong, not that the package or your permissions are.
The most common concrete cause is a mismatch between the auth line's host and the registry host. npm matches //host/:_authToken by exact host, including the trailing slash and any port, so //npm.pkg.github.com/ will not authenticate a request to https://npm.pkg.github.com if the stored key is subtly different. The second is an unexpanded ${TOKEN} because the environment variable is absent — the .npmrc then sends an empty token and the registry rejects it.
Resolution and configuration patch
Match the auth line's host to the registry host exactly and provide a scoped token:
# .npmrc
@acme:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
# Export a token with the right scopes before install
export NODE_AUTH_TOKEN=ghp_xxx # read:packages (+ write:packages to publish)
npm install
When the token is correct but still rejected, confirm it carries the scopes the operation needs — reading a private package requires read:packages, publishing requires write:packages:
# Fail early in CI if auth is broken, before install runs
npm whoami --registry=https://npm.pkg.github.com || {
echo 'registry auth failed'; exit 1;
}
Running whoami as an early step turns a confusing mid-install 401 into a clear, up-front failure that names the registry, which is far easier to debug than a stack of resolution errors.
CLI validation and debug commands
# Confirm which registry a scope resolves to
npm config get @acme:registry
# Verify the token is present and valid
npm whoami --registry=https://npm.pkg.github.com
# Inspect the exact request npm makes
npm install --loglevel=http 2>&1 | grep 401
Prevention and CI guardrails
- Key the
_authTokenline to the exact registry host, including the trailing slash. - Mint tokens with only the scopes the job needs (
read:packages,write:packages). - Reference the token via
${NODE_AUTH_TOKEN}and inject it from secrets, never commit it. - Assert
npm whoami --registry=<host>succeeds as an early CI step so auth fails fast.
401, 403, and 404 — reading the status code
The three failure codes point at three different fixes, and treating them interchangeably wastes hours. A 401 means the request was unauthenticated: the token is missing, wrong, or bound to the wrong host. A 403 means it authenticated but lacks permission: the token is valid but does not have access to that package or scope. A 404 usually means the scope was never mapped to the private registry, so npm queried the public one, which genuinely does not have your package.
The diagnostic order follows from that. On a 401, check the _authToken host binding and that the environment variable expanded. On a 403, check the token's scopes and the account's access to the org or package. On a 404, check that @scope:registry points at the private host. Reading the code first — rather than blindly regenerating tokens — takes you to the right layer immediately.
Making registry auth fail fast in CI
Auth problems are cheapest to fix when they surface early and name the registry, so build that signal into the pipeline rather than letting a 401 emerge halfway through an install. A dedicated verification step, run before any install, converts an opaque failure into a clear one.
- name: Verify registry auth
run: npm whoami --registry=https://npm.pkg.github.com
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
When this step fails, the log says exactly which registry rejected the credential, so you know immediately whether the problem is the token, its scopes, or the host binding — instead of inferring it from a resolution error three minutes into the job. It also documents, for the next engineer, exactly which token the pipeline expects and where.
Frequently Asked Questions
Why does npm login not fix the 401?
npm login writes auth for the default registry. A scoped private host needs its own //host/:_authToken line; without it, requests to that host are anonymous and return 401.
The token works locally but 401s in CI — why?
The environment variable the .npmrc references is not set in the CI job, so ${NODE_AUTH_TOKEN} expands to empty. Inject it from the job's secrets with the correct scopes.
What's the difference between a 401 and a 403 here?
A 401 is an authentication failure — no valid credential reached the registry. A 403 means the credential authenticated but lacks permission for that package or scope. Fix the token/host binding for a 401; fix the token's scopes or access for a 403.
Why does the token work locally but 401 in CI?
The environment variable your .npmrc references is not set in the CI job, so ${NODE_AUTH_TOKEN} expands to empty and the registry rejects the anonymous request. Inject the token from the job's secrets with the required scopes.
How do I make a 401 easier to debug?
Add an early npm whoami --registry=<host> step. It fails fast and names the registry, turning a confusing mid-install error into a clear up-front signal about which credential is wrong.
Related
- Private Registries and Access Control — the overview for private distribution and access control.