Least-privilege npm tokens
Most npm automation tokens have more permissions than they need. A CI pipeline that only needs to publish one package uses a token that could publish every package the account maintains, access the account's settings, and read private package contents. When that token is stolen — and CI token theft is one of the most common supply chain attack vectors — the blast radius is the entire account.
npm has supported granular access tokens since 2021. Most teams aren't using them.
What granular access tokens add
Classic npm automation tokens (created with npm token create) are scoped to the entire account. They can publish any package the account has access to and have full read access to private packages.
Granular access tokens (created at npmjs.com/settings/[username]/tokens/granular or via the npm website) support:
Package scope: The token can only read or publish specific packages. A token scoped to @acme/auth-utils cannot be used to publish @acme/data-client.
Organization scope: The token can only act on packages within a specific npm organization.
Read vs read-write: A token used only for npm install in a CI pipeline needs read access only. Make it read-only.
IP allowlist: The token only works from specific IP addresses. For GitHub Actions, you can add GitHub Actions' IP ranges; requests from other IPs are rejected.
Expiry date: The token automatically becomes invalid after a specified date. For a token used in a CI pipeline that you'll revisit quarterly, set a 90-day expiry.
Creating a granular token
Via the npm website (npmjs.com/settings/[username]/tokens/granular):
- Name: something descriptive — "acme-auth-utils-publish-github-actions"
- Expiration: 90 days (set a calendar reminder to rotate)
- IP allowlist: GitHub Actions IP ranges (from GitHub's meta API:
https://api.github.com/meta) - Packages and scopes: select only
@acme/auth-utils, publish permission
The result: a token that can only publish @acme/auth-utils, only from GitHub Actions IPs, and expires in 90 days. If this token is stolen, the attacker can only publish @acme/auth-utils, only from within GitHub Actions, for at most 90 days.
Storing the token in GitHub Actions
# Store as a GitHub Actions secret: NPM_PUBLISH_TOKEN
# This secret is only needed in the publish workflow
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
registry-url: 'https://registry.npmjs.org'
- run: npm publish
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_PUBLISH_TOKEN }}
The NPM_PUBLISH_TOKEN secret is only available in the publish workflow, not in the test workflow. A supply chain attack that runs during npm ci in the test workflow cannot access the publish token.
Rotating tokens
Set a recurring calendar reminder every 90 days to rotate your npm tokens:
# Revoke old token
npm token revoke <old-token-id>
# Create new granular token via the website
# Update the secret in GitHub Actions / your secret manager
Token rotation limits the window of exposure if a token has been silently compromised — stolen tokens often aren't used immediately; attackers wait for an opportune moment.
Auditing existing tokens
# List all tokens on your account
npm token list
# Revoke tokens you don't recognize or no longer use
npm token revoke <token-id>
Most accounts have more tokens than they realize. CI pipelines that have been set up over years, local development tokens from machines that have been replaced, tokens from old projects — these accumulate. Audit and revoke any that aren't actively needed.
Least-privilege npm tokens limit the blast radius of a token theft. Add Veln to catch the supply chain attacks that token theft enables.