What ENEEDAUTH is: npm decides it before any request. In every ENEEDAUTH case below the mock registry logged no PUT and no whoami request at all. It means "I have no credential for the registry URL this command is going to" — it is not the registry refusing you. A token that is present but wrong gives E401 (whoami) or E403 (publish) instead, and those requests do reach the registry.
Results (identical on all three npm versions): no .npmrc token, npm publish → ENEEDAUTH "requires you to be logged in to <registry>" no token, npm whoami → ENEEDAUTH "requires you to be logged in." token present but invalid, npm whoami → E401 Unauthorized (request reached the registry) token present, publish refused → E403 Forbidden (request reached the registry) .npmrc //127.0.0.1:4873/:_authToken=${NODE_AUTH_TOKEN} with NODE_AUTH_TOKEN set to the empty string → ENEEDAUTH the same line with NODE_AUTH_TOKEN not set at all → NOT ENEEDAUTH: npm sent the literal text ${NODE_AUTH_TOKEN} as the token and the registry answered 403. An unset variable surfaces as E401/E403, an empty one as ENEEDAUTH. token saved for //localhost:4873/ while the registry is http://127.0.0.1:4873/ → ENEEDAUTH (host must match as written) token saved for //127.0.0.1:4873/ while the registry is http://127.0.0.1:4873/npm/ → ENEEDAUTH (the token key includes the path) scoped package whose publishConfig.registry points elsewhere, token only for the default registry → ENEEDAUTH (the publish goes to publishConfig's registry, and there is no token for it) GitHub Actions OIDC variables set but the token endpoint unreachable, no npm token → ENEEDAUTH on all three versions
How to read your error: the URL in "logged in to <url>" is the registry npm resolved for this command. Run npm config get registry, check package.json publishConfig and any @scope:registry line, then look for an //<host>/<path>/:_authToken= line whose host AND path match that URL exactly. In CI, print whether the token variable is empty (never the value): [ -n "$NODE_AUTH_TOKEN" ] && echo set || echo empty. An empty secret (a fork PR, a secret not given to this environment, a typo in the secret name) is the usual CI cause.
Trusted publishing (OIDC) in GitHub Actions: our own SDK repository publishes with id-token: write, upgrades npm first (npm install -g npm@^11.15.0), and deliberately does not set registry-url / always-auth on actions/setup-node, because with them npm looks for a token and fails. If you see ENEEDAUTH in a trusted-publishing job, check those three things before anything else.
Caveat: the registry here is a mock; the decision this unit measures (no matching credential → ENEEDAUTH without a request) is npm's own and was the same with any URL. Registry-side answers (E401/E403 wording) on registry.npmjs.org can differ in text.