A publish job fails with an error that names no cause:

npm error code ENEEDAUTH
npm error need auth This command requires you to be logged in to https://npm.pkg.github.com
npm error need auth You need to authorize this machine using `npm adduser`

The token is in the CI secret store, the environment variable is set, and the publish still runs anonymously. Almost always the auth line’s host key does not match the registry URL, or a different .npmrc is winning.


Root Cause

npm authenticates per host, using a configuration key of the form //<host>/:_authToken. The host portion must match the registry URL npm is actually contacting, and it is written without the protocol scheme but with the trailing slash. //npm.pkg.github.com/:_authToken authenticates requests to https://npm.pkg.github.com; //npm.pkg.github.com:_authToken (no slash) does not, and neither does an entry for a bare hostname when the registry URL includes a path.

Layered on top is file precedence. npm merges configuration from several sources — command-line flags, environment variables, the project .npmrc, the user’s ~/.npmrc, then a global file — and the first definition of a key wins. A leftover token in a home directory can quietly authenticate as the wrong identity, which is worse than failing.


Minimal Reproduction

# .npmrc committed in the repository
@acme:registry=https://npm.pkg.github.com
_authToken=${NODE_AUTH_TOKEN}
- run: npm publish
  env:
    NODE_AUTH_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

The variable is set and the token is valid. The publish is anonymous, because _authToken without a host key is a default-registry setting and the request is going to a different host.


How npm Resolves Credentials

Where npm looks for a token npm merges configuration from command-line flags, environment variables, the project npmrc, the user's home npmrc and a global file, in that order, and the first definition of a key wins. First definition wins — including one you forgot about 1. command-line flags --registry=… on the publish command itself 2. environment variables npm_config_registry and friends 3. project .npmrc the one you edited, and expected to win 4. ~/.npmrc stale tokens live here and authenticate silently 5. global npmrc — rarely relevant, occasionally decisive

Step-by-Step Fix

1. Write the auth line with a host key

   @acme:registry=https://npm.pkg.github.com
- _authToken=${NODE_AUTH_TOKEN}
+ //npm.pkg.github.com/:_authToken=${NODE_AUTH_TOKEN}
   always-auth=true

The always-auth line matters for registries that require credentials on reads as well as writes; without it npm may fetch metadata anonymously and only authenticate the upload, which fails on registries with no anonymous tier.

2. Confirm which configuration is actually in effect

npm config list                      # merged view, with file provenance
npm config get //npm.pkg.github.com/:_authToken   # prints (protected) when set
npm whoami --registry=https://npm.pkg.github.com
; project .npmrc
@acme:registry = "https://npm.pkg.github.com"
; node bin location = /usr/local/bin/node
acme-release-bot

npm whoami against the specific registry is the single most useful diagnostic here: it answers “am I authenticated to this host” without publishing anything.

3. Generate the file in CI rather than committing one

Where the registry URL varies per environment, writing the file at build time is clearer than templating a committed one:

- name: Configure registry auth
  run: |
    cat > .npmrc <<EOF
    @acme:registry=${REGISTRY_URL}
    //${REGISTRY_URL#https://}/:_authToken=${REGISTRY_TOKEN}
    always-auth=true
    EOF
  env:
    REGISTRY_URL: https://npm.pkg.github.com
    REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}

- run: npm publish

- name: Remove credentials
  if: always()
  run: rm -f .npmrc

The if: always() on the cleanup step matters — a failed publish should not leave a credential file behind on a self-hosted runner.

HAZARD PREVENTION

Symptom: A publish succeeds but lands on the wrong registry, or as the wrong user.

Root cause: A ~/.npmrc on a self-hosted runner supplied a registry or token with higher precedence than the project file, so the job authenticated as an identity nobody configured for it.

Fix: Run publishes on ephemeral runners where possible, and where they must be self-hosted, pass --userconfig pointing at a job-local file so the home directory is bypassed entirely: npm publish --userconfig ./.npmrc.ci.

4. Handle container builds with a mounted secret

# syntax=docker/dockerfile:1
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
    npm ci --omit=dev
COPY . .
docker build --secret id=npmrc,src=$HOME/.npmrc-ci -t my-app .

The secret is available for that one instruction and is not written into any layer. Copying .npmrc into the image and deleting it later does not work: the earlier layer still contains it and can be extracted from a pushed image.


Verification

# authentication works against the target registry
npm whoami --registry="$REGISTRY_URL"

# no literal token was committed anywhere
git grep -nE '_authToken=[^$]' -- '*.npmrc' && echo "LITERAL TOKEN COMMITTED" || echo "ok: no literal tokens"

# no credential survives in a built image layer
docker history --no-trunc my-app | grep -c "_authToken" || echo "ok: no token in layers"

Expected: the bot username, ok: no literal tokens, and ok: no token in layers.


Edge Cases / Gotchas

  • pnpm and Yarn read .npmrc with small differences. pnpm honours the same auth keys; Yarn Berry uses its own yarnrc.yml with a different syntax, so a monorepo using both needs credentials in two formats.
  • Environment expansion only applies to ${VAR} form. A bare $VAR is not expanded and is sent literally as the token, producing an authentication failure that looks like an invalid credential.
  • Scoped registry lines and auth lines are independent. Configuring the scope without the matching auth line produces anonymous requests to the right host, which fail differently from requests to the wrong host.
  • Tokens in URLs are worse than tokens in files. Some documentation shows credentials embedded in a registry URL; those end up in lockfiles and logs.
  • npm config set writes to the user file by default. Running it during a job modifies ~/.npmrc on a self-hosted runner and persists to every subsequent job on that machine.

Frequently Asked Questions

Can a committed .npmrc reference an environment variable?

Yes. npm expands ${VAR} references at read time, so a committed file can contain an auth line whose value comes from the environment — the standard way to version the configuration without the secret.

Why is my token ignored even though the environment variable is set?

Auth lines are keyed by host and the key must match the registry URL exactly, including the trailing slash. A mismatch produces an anonymous request rather than a configuration error.

Which .npmrc wins when several exist?

Command-line flags, then environment variables, then the project file, then ~/.npmrc, then the global file — first definition wins. A stale home-directory token can authenticate a publish you thought was using the project configuration.

How should a token reach a container build?

As a build secret mounted for the install step only. Copying it into the image bakes the credential into a layer that deleting it later does not remove.

Is it safe to echo the .npmrc for debugging?

No. Masking can be defeated by reformatting or partial interpolation. Diagnose with npm whoami --registry=… instead.



↑ Back to Publishing to Private and Alternative Registries