npm granular access tokens are capped at 90 days. There's no way to mint one that lasts longer, so installing private packages means rotating a token four times a year.
OIDC doesn't help here. It gives CI an identity a registry can trust, which covers publishing, but my laptop has no such identity to present. This discussion has people working through the same thing and arriving nowhere better.
Multiply that by everyone
On its own the rotation is a minor chore. You run an install, it fails on a 401, and you spend a few minutes generating a token and pasting it into ~/.npmrc.
In an organization it isn't one chore. The credential is shared, so someone rotates it and updates the vault, and then every developer still has to get the new token into their own ~/.npmrc, and nothing prompts them to. They find out when an install fails, which is to say at the least convenient moment available, four times a year, each of them separately.
So the token I needed was already in 1Password. The copy on disk had just gone stale.
pnop
pnop is a small Go binary that forwards everything to pnpm. If a command succeeds, it does nothing at all.
If a command fails, it asks the registry whether the token in your npmrc is still accepted: GET <registry>/-/whoami. Only a 401 leads anywhere.
- 200. The token works, so the failure was something else. Nothing happens.
- 401. Run your token command, and write what it prints if that one is live.
- 403, 404, 405, 5xx. Nothing. That describes the endpoint, not your credential.
- No answer at all. Nothing. Being offline is not evidence about a token.
I got here by way of two worse designs. The first inspected the command, with a list of subcommands that never touch the registry, which cannot work: pnpm runs any package.json script as a bare subcommand, so a script shelling out to a compiler and a real pnpm command hitting the registry are indistinguishable from the outside. The second read pnpm's output for auth errors, which worked until the wording changed between pnpm majors, twice, while the fix stayed the same. Asking the registry is a question about the credential answered by the thing that issued it.
Nothing runs on the happy path. A successful command reads no config, contacts no registry, and never invokes your token command.
It does not rerun your command. This one took me a while to accept. pnop cannot see whether the failed attempt got far enough to have a side effect, so it writes the token, tells you, and gets out of the way:
[pnop] refreshed the npm token in /Users/you/.npmrc
[pnop] run it again: pnpm add @scope/pkg
Rerunning is opt-in, and when it does rerun it marks its own environment so one refresh is the budget even if a pnpm script calls pnpm.
There's also a case it deliberately won't fix. If your token is valid but too narrow for the package you asked for, whoami answers 200 while pnpm answers 404, because identity and authorization are different questions. Nothing to refresh there.
Setup
brew install --cask frodi-karlsson/tap/pnop
pnop +setup -c work --command 'op read "op://Employee/npm/token"'
Anything that prints a token on stdout works, so pnop needs to know nothing about where you keep one. Mine is in 1Password. A keychain lookup or vault kv get would do as well. The output can be a bare token or a whole //registry.npmjs.org/:_authToken=<token> line.
The command runs through sh, with stdin and stderr wired to your terminal so a biometric prompt still works. That does mean the config file gets executed rather than read. It's yours and mode 0600, the same trust you already extend a shell rc, but it's code and worth knowing that.
pnop's own commands carry a +: +setup, +refresh, +version, +help. Everything without one belongs to pnpm. pnpm has a setup of its own, and a repo script named refresh stays reachable as plain pnpm refresh.
Setup probes what it fetched and says whose it is:
[pnop] registry.npmjs.org accepts this token right now, as frodi
You can define more than one config and switch between them, which I use for work and personal registries:
pnop +setup -c personal --command 'op read "op://Personal/npm/token"'
pnop +setup -c personal # switch
pnop +setup -c work # switch back
Switching rewrites the npmrc with that config's token. When I want it rewritten without waiting for something to fail, pnop +refresh reruns the command on demand.
Then in my shell config:
alias pnpm=pnop
An alias only covers what you type. Scripts that call pnpm, package.json scripts included, never see it, so covering those means a shim earlier in your PATH instead.
What this doesn't solve
CI. The 90-day rotation still applies to whatever token your pipeline uses, and pnop has no answer for that. It's a local tool that happens to know where my token lives. The discussion above lists the actual options there: GitHub Packages with the built-in token, a registry proxy, or an automated rotation workflow. All of them are more work than an alias.
It also doesn't make the expiry go away. It moves the recovery from "notice, open a browser, generate, paste, run it again" to just "run it again," which is as much as I wanted from it.