Npm's granular access tokens were introduced in December 2022 and hit general availability in early 2023. As of npm 10.9 in late 2024, they are the recommended token type for every publish flow, and classic tokens are best understood as a legacy compatibility option. Despite that, in the organizations I audit, classic tokens still outnumber granular tokens by a wide margin. The reasons are usually a combination of habit, time pressure, and a few specific gotchas that make the migration less automatic than the docs suggest.
This post is the migration playbook I have used across four organizations. It is opinionated, and it assumes you have already decided to migrate; I am not going to re-argue the case.
What A Granular Token Actually Is
A granular access token is an npm credential with three properties that classic tokens lack: a required expiration (up to one year), a package scope restriction (publish only to these packages or scopes), and a permission profile (read, publish, or both).
Under the hood it still begins with npm_ and has the same wire format as a classic token. The differences are entirely server-side: when npm receives an auth header with a granular token, it checks the scope and permission at the time of the request, where a classic token grants whatever the creating user had.
The expiration is the feature I care about most. Classic tokens, once created, can live forever. I have found classic tokens in production configs that were created in 2019 and never rotated. Granular tokens force rotation at least annually by construction.
Pre-Migration Inventory
The first step is the same as any token-management work: you need to know what you have. Run npm token list to enumerate classic tokens for each npm account your org uses. Granular tokens do not fully enumerate via the CLI as of npm 10.9 (this has been a known gap since GA); use the web UI at https://www.npmjs.com/settings/<username>/tokens and its sibling endpoint for org-level tokens.
For each classic token, record: the creating account, where it is deployed (which CI system, which secret), which packages it actually publishes, and what level of 2FA bypass it carries (tokens marked automation bypass 2FA; tokens marked publish or readonly do not).
The inventory step, boring as it is, is where most migrations stall. I do not accept "we have a list somewhere" as an inventory. I want a CSV with columns for token hint (the last four characters), creator, deployment location, and package scope used.
The Mapping Step
For each classic token, decide what granular token will replace it. The key question is: what is the minimum package scope and permission this credential needs?
Most CI publish tokens only publish to one package or one organization scope. A token that currently has full publish rights across the org but only ever publishes @company/logger should be replaced with a granular token scoped to @company/logger alone.
The surprising case is read-only tokens for CI installs of private packages. These almost always need read access to every private package in the org (because the CI job installs a variety of transitive private dependencies). A granular read-only token scoped to the org's full scope is the right replacement; narrower than that and you will break installs.
Some classic tokens are used for interactive developer workflows and have no clear single purpose. For those, decide whether the token should be replaced with a developer-specific granular token or whether the developer should authenticate via npm login each session. The latter is more secure but operationally annoying. I push for npm login with 2FA for developers and granular tokens only for automation.
The Rollout
For each classic token, the rollout is:
- Create the granular replacement with the correct scope and expiration.
- Deploy the new token to the secret store used by the consumer.
- Trigger a canary job that exercises the new token in the same flow the old token was used for.
- If the canary passes, revoke the old token.
- If the canary fails, revert the secret store to the old token and investigate.
The canary is the part people skip and regret. In January 2024 I watched a migration fail because the new granular token was scoped to @company but the CI job was publishing to @company-legacy, a scope nobody had remembered existed. Without the canary, the first production publish would have failed; with the canary, we caught it in five minutes.
OIDC As A Replacement
For GitHub Actions workflows, there is a third option: skip the granular token entirely and migrate to OIDC trusted publishers. This requires configuring the package at the registry to trust a specific GitHub Actions workflow. Once configured, the workflow can publish without any token at all; the OIDC identity is the credential.
I push for OIDC whenever the CI is GitHub Actions. The configuration takes about fifteen minutes per package and eliminates the rotation problem entirely. GitLab support for OIDC trusted publishers was rolled out in preview in mid-2024 and went GA for npm later that year; other CI systems will follow.
Gotchas Nobody Writes Down
A few pitfalls I have hit.
First: granular tokens can be scoped to "organization" rather than individual packages, but that scope applies only to packages owned by the org at the time of token creation. A package added to the org after the token was created is not covered. This caught me in March 2024: we created an org-scoped token, published a new package a month later, and publishes to the new package failed with a surprising 401. The fix was to recreate the token. Now I use package-level scopes whenever possible and only fall back to org scope for read operations.
Second: the expiration is a hard cutoff with no warning. A token expiring on October 1 at 00:00 UTC stops working at exactly that moment. Build a calendar of expirations and rotate at least two weeks before. Npm does email the creator, but in an org where the creator has rotated roles, that email goes to someone who does not own the rotation.
Third: granular tokens and classic tokens can coexist for the same user. If you forget to revoke a classic token after deploying a granular replacement, both credentials work. During an incident in April 2024 this bit a client: an attacker used a classic token that had been "replaced" six months earlier but not revoked. The lesson: revocation is not optional.
Fourth: the npm token revoke CLI command does not work on granular tokens in some npm CLI versions I have tested (10.2 and earlier, specifically). Use the web UI or the /settings/<user>/tokens/<id> endpoint directly for granular token revocations. This is annoying but known.
Verifying The Migration
When the migration is nominally complete, run a verification sweep. For each account, npm token list should return zero classic tokens (or only tokens you explicitly want to keep, such as a documented break-glass token). The inventory CSV should show every credential in the "granular" state.
Do a spot check on the secret stores: grep for classic-token shapes (npm_[A-Za-z0-9]{36} is the broad match; classic vs granular is not distinguishable from the string alone, so the real check is against npm's inventory). Confirm that the expiration date on every granular token is within one year.
How Safeguard Helps
Safeguard inventories npm tokens across your organization and tags each as classic or granular, expiring-soon or current, and scoped or unscoped. For the migration itself, Safeguard produces the per-token replacement plan, tracks progress through the rollout, and runs canary installs to verify each new token before the old one is revoked. For GitHub Actions workflows, we identify which are eligible for OIDC trusted publishers and generate the configuration. If your org has any classic tokens still alive, Safeguard surfaces them in the security dashboard with the path to retirement. The migration is a solvable project and the tooling is there to make it sequenceable instead of chaotic.