Before you continue, please read and agree to the Terms of Service and Optimism Community Agreement.

Before you continue, please read and agree to the Terms of Service and Optimism Community Agreement.

Raffaele

Raffaele Mazzitelli

Raffaele Mazzitelli

Stop Rotating npm Tokens. Delete Them.

TL;DR

  • Nearly every major CI-mediated npm supply-chain breach of the last five years traces back to a long-lived credential sitting somewhere it shouldn't.

  • We migrated three repos and six packages to OIDC Trusted Publishing on CircleCI, then deleted every static npm token on the account, including the service account that only existed to hold one.

  • OIDC alone is not enough. You also need to set each package to "Require 2FA and disallow tokens" to close the maintainer-laptop path (the axios / WAVESHAPER class).

  • Rotation does not close the theft window. Deletion does.

The only credential that can't be stolen at rest is the one that doesn't exist.

Why: the case against static tokens

For years, publishing to npm from CI meant storing a long-lived access token as an environment variable. Three incidents shaped our thinking, and they split cleanly into two distinct threat classes.

Incident timeline

Year

Incident

Root cause

Class

Fix

2023

CircleCI breach

Malware stole a 2FA SSO session, and the attacker exfiltrated customer env vars, including stored NPM_TOKENs

1: CI-resident token

OIDC Trusted Publishing

2025

Shai-Hulud worm

Self-replicating package harvested NPM_TOKENs and GitHub PATs from dev/CI environments, then republished trojanized packages

1: CI-resident token

OIDC Trusted Publishing

2026

axios / WAVESHAPER

Social-engineered maintainer; persistent publish token on maintainer laptop used to ship malicious versions directly

2: Maintainer-resident token

Registry-side: "Require 2FA, disallow tokens"

The two token classes

  • Class 1, CI/CD tokens. Addressed by OIDC Trusted Publishing. CI no longer needs a stored token; nothing in the build environment for a platform breach, a worm, or an insider to exfiltrate.

  • Class 2, maintainer-laptop tokens. These survive a clean OIDC migration, because OIDC only governs CI-origin publishes. The registry will still accept a valid static token from anywhere else. Fixed only by a registry-side policy that rejects token-based publishes entirely.

The underlying truth: if a credential is persistent, it is stealable. Rotation doesn't close the window. The window between rotations is the window an attacker operates in.

How: identity, not secrets

On April 6, 2026, npm added Trusted Publishing support for CircleCI. We migrated shortly after.

The OIDC exchange

At publish time:

  1. CircleCI issues a signed JWT identifying the workflow, job, and branch running.

  2. The npm registry validates issuer, subject, and audience against the Trusted Publisher configuration on the package.

  3. On match, npm mints a short-lived token scoped to that one package.

  4. The build uses the token and discards it.

Resulting credential properties:

Property

Value

Lifetime

Minutes

Scope

Single package

Binding

Workflow identity (JWT)

Exists at rest?

No

Wiring it into CircleCI

The three legacy steps around NPM_TOKEN (check, write to .npmrc, npm whoami) collapse to a single OIDC fetch:

# Requires: npm >= 11.5.1, Node >= 22.14.0
# (What worked for us. Check current npm docs for today's minimums.)
- run:
    name: Obtain OIDC token for npm trusted publishing
    command: |
      set -e
      # Audience MUST be exactly "npm:registry.npmjs.org", not the bare registry URL.
      NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}')

      if [ -z "$NPM_ID_TOKEN" ]; then
        echo "ERROR: circleci run oidc get returned an empty token" >&2
        exit 1
      fi

      # Export via $BASH_ENV so the token persists to later steps.
      # A plain `export` dies with this shell; the publish step wouldn't see it.
      echo "export NPM_ID_TOKEN=$NPM_ID_TOKEN" >> "$BASH_ENV"
# Requires: npm >= 11.5.1, Node >= 22.14.0
# (What worked for us. Check current npm docs for today's minimums.)
- run:
    name: Obtain OIDC token for npm trusted publishing
    command: |
      set -e
      # Audience MUST be exactly "npm:registry.npmjs.org", not the bare registry URL.
      NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}')

      if [ -z "$NPM_ID_TOKEN" ]; then
        echo "ERROR: circleci run oidc get returned an empty token" >&2
        exit 1
      fi

      # Export via $BASH_ENV so the token persists to later steps.
      # A plain `export` dies with this shell; the publish step wouldn't see it.
      echo "export NPM_ID_TOKEN=$NPM_ID_TOKEN" >> "$BASH_ENV"
# Requires: npm >= 11.5.1, Node >= 22.14.0
# (What worked for us. Check current npm docs for today's minimums.)
- run:
    name: Obtain OIDC token for npm trusted publishing
    command: |
      set -e
      # Audience MUST be exactly "npm:registry.npmjs.org", not the bare registry URL.
      NPM_ID_TOKEN=$(circleci run oidc get --claims '{"aud":"npm:registry.npmjs.org"}')

      if [ -z "$NPM_ID_TOKEN" ]; then
        echo "ERROR: circleci run oidc get returned an empty token" >&2
        exit 1
      fi

      # Export via $BASH_ENV so the token persists to later steps.
      # A plain `export` dies with this shell; the publish step wouldn't see it.
      echo "export NPM_ID_TOKEN=$NPM_ID_TOKEN" >> "$BASH_ENV"

Working reference: ethereum-optimism/ecosystem .circleci/config.yml. The original PR #992 needed follow-up fixes. Copy from the linked commit, not the PR.

Gotchas that bit us:

  • Stock CI images often ship an npm too old for OIDC publishing. Upgrade explicitly.

  • The audience claim is npm:registry.npmjs.org. Not a URL. Not with a scheme. Exactly that.

  • Trusted Publisher config lives on the npm package page, not in account or org settings. It is per-package: there is no org-wide toggle. Ten packages means ten UI trips, or one scripted loop (see below).

The branch-identity loophole

npm's Trusted Publisher config for CircleCI lets you pin org, project, and pipeline definition, but not branch. On its own, npm will accept a publish from any branch that runs the matching workflow.

If you rely solely on filters: branches: only: main in the workflow YAML, you are vulnerable: anyone with push access can create a feature branch, delete that filter, commit malicious code, and trigger a live publish.

To actually constrain publishes to main:

  1. Create a CircleCI context with a branch restriction (only main can access it).

  2. Bind the publish job to that context.

  3. Pass that context ID to the npm Trusted Publisher config (--context-id).

How this closes the loophole: CircleCI emits an oidc.circleci.com/context-ids claim in every OIDC token, listing the UUIDs of contexts the job had access to. A feature-branch run can't access the branch-restricted context, so that UUID won't appear in the claim, and npm rejects the publish because the JWT doesn't match the pinned --context-id in the trust policy.

Scripting the per-package setup

Configure every package in one pass with npm trust:

# Flag names have churned across npm versions. Run `npm trust circleci --help`
# to confirm, since `--pipeline-definition-id` has appeared as `--pipeline-id` before.
for pkg in "@your-scope/pkg-one" "@your-scope/pkg-two" "@your-scope/pkg-three"; do
  # To re-configure an existing trust:
  # npm trust revoke "$pkg" --id="<current-trust-id>"

  # --context-id is the branch-restricted CircleCI context doing its job:
  # without it, a feature-branch run could mint a publish-capable OIDC token.
  npm trust circleci "$pkg" \\
    --org-id <CIRCLECI_ORG_ID> \\
    --project-id <CIRCLECI_PROJECT_ID> \\
    --pipeline-definition-id <CIRCLECI_PIPELINE_DEFINITION_ID> \\
    --vcs-origin github.com/<your-org>/<your-repo> \\
    --context-id <CIRCLECI_CONTEXT_ID> \\
    --yes
done
# Flag names have churned across npm versions. Run `npm trust circleci --help`
# to confirm, since `--pipeline-definition-id` has appeared as `--pipeline-id` before.
for pkg in "@your-scope/pkg-one" "@your-scope/pkg-two" "@your-scope/pkg-three"; do
  # To re-configure an existing trust:
  # npm trust revoke "$pkg" --id="<current-trust-id>"

  # --context-id is the branch-restricted CircleCI context doing its job:
  # without it, a feature-branch run could mint a publish-capable OIDC token.
  npm trust circleci "$pkg" \\
    --org-id <CIRCLECI_ORG_ID> \\
    --project-id <CIRCLECI_PROJECT_ID> \\
    --pipeline-definition-id <CIRCLECI_PIPELINE_DEFINITION_ID> \\
    --vcs-origin github.com/<your-org>/<your-repo> \\
    --context-id <CIRCLECI_CONTEXT_ID> \\
    --yes
done
# Flag names have churned across npm versions. Run `npm trust circleci --help`
# to confirm, since `--pipeline-definition-id` has appeared as `--pipeline-id` before.
for pkg in "@your-scope/pkg-one" "@your-scope/pkg-two" "@your-scope/pkg-three"; do
  # To re-configure an existing trust:
  # npm trust revoke "$pkg" --id="<current-trust-id>"

  # --context-id is the branch-restricted CircleCI context doing its job:
  # without it, a feature-branch run could mint a publish-capable OIDC token.
  npm trust circleci "$pkg" \\
    --org-id <CIRCLECI_ORG_ID> \\
    --project-id <CIRCLECI_PROJECT_ID> \\
    --pipeline-definition-id <CIRCLECI_PIPELINE_DEFINITION_ID> \\
    --vcs-origin github.com/<your-org>/<your-repo> \\
    --context-id <CIRCLECI_CONTEXT_ID> \\
    --yes
done

Strongly recommended over the UI for more than two or three packages. Also the one-command fix for misconfigurations.

What changes, and what doesn't

Closed

  • Persistent NPM_TOKEN in CircleCI. Gone.

  • CI-token exfiltration path (CircleCI 2023, Shai-Hulud 2025). Closed for our packages.

Still open

OIDC binds a release to a workflow and branch. It does not protect against:

  • A malicious commit merged into a main release branch. The OIDC exchange will happily sign a publish of bad code.

  • An attacker who can modify the workflow file or the Trusted Publisher config on https://www.npmjs.com/.

  • Compromise of the upstream dependency tree at build time.

  • A compromised maintainer account with a static token on their laptop. Deleting CI tokens does not disable password- or token-based publishing from a workstation.

To close that last path, set each package's Publishing access to "Require two-factor authentication and disallow tokens." OIDC-minted credentials are exempt from this setting, so CI keeps working while the registry rejects any static token, no matter who minted it.

Trusted Publishing is one layer, not the finish line. Branch protection, required reviews, dependency pinning, and build provenance all still matter.

Stop rotating. Start deleting.

The migration (roughly 30 min per package)

  1. Add the Trusted Publisher on npm (package Settings → Publishing access → CircleCI).

  2. Upgrade the publish job (npm >= 11.5.1, Node >= 22.14.0 for us) and swap NPM_TOKEN setup for circleci run oidc get.

  3. Run a release. Confirm the publish succeeds and the build no longer reads NPM_TOKEN.

  4. Delete every static npm token on the account, and retire service accounts that existed only to hold them. This is the step that changes your security posture.

  5. Set every package to "Require 2FA and disallow tokens." Makes static-token publishes impossible at the registry level.

  6. Move publish teams to read-only on npm. CI publishes via OIDC; human publishes go through org owners with hardware 2FA. A phished developer account becomes worthless for publishing.

The last token you'll ever need

Doing step 5 through the UI means clicking into every package. Passkey-based CLI 2FA means one tap per package. Neither is fun at scale.

The pragmatic move: create one short-lived, broadly-scoped, bypass-2FA token specifically for this operation, use it, then delete it.

  1. Create a granular access token on npm with:

    • Read+write on your entire scope (@your-scope/*)

    • Bypass 2FA enabled

    • Expiration: 1 day

    • Name it memorably: super-powerful-temporary-delete-me-now-I-mean-it

  2. Verify the flag mapping on one package first.

The npm CLI flag for the "disallow tokens" radio has shifted across versions. Both mfa=publish and mfa=automation have at different times produced the correct UI state. Run the command on one package, open Settings → Publishing access in the browser, and confirm the selected option is "Require two-factor authentication and disallow tokens" before running anything in bulk.

  1. Run the loop:

export NPM_TOKEN=npm_XXX

# As each package hardens, the registry refuses token-based publishes to it,
# including from this very token. By the end, this token can publish nothing.
for pkg in $(npm access list packages "@your-scope" --json | jq -r 'keys[]'); do
  npm access set mfa=publish "$pkg"   # use the value you verified above
done
export NPM_TOKEN=npm_XXX

# As each package hardens, the registry refuses token-based publishes to it,
# including from this very token. By the end, this token can publish nothing.
for pkg in $(npm access list packages "@your-scope" --json | jq -r 'keys[]'); do
  npm access set mfa=publish "$pkg"   # use the value you verified above
done
export NPM_TOKEN=npm_XXX

# As each package hardens, the registry refuses token-based publishes to it,
# including from this very token. By the end, this token can publish nothing.
for pkg in $(npm access list packages "@your-scope" --json | jq -r 'keys[]'); do
  npm access set mfa=publish "$pkg"   # use the value you verified above
done
  1. Delete the token. (It can still make admin-level settings changes, so this step is not optional.)

This is the exact credential class Axios missed: a long-lived, bypass-2FA token on a compromised machine, used by WAVESHAPER to publish. The lesson isn't "never create bypass-2FA tokens" (we just did). It's "never let them outlive the operation that needed them."

Definition of Done

Your account meets the bar when all of these are true:

  • Every publishing package has a Trusted Publisher configured on npm.

  • Every Trusted Publisher is bound to a branch-restricted CircleCI context (not just a workflow YAML filter).

  • The publish job fetches an OIDC token at runtime; no NPM_TOKEN is referenced.

  • A release has succeeded end-to-end under the new config.

  • Every static npm token on the account is deleted.

  • Every service account that existed only to hold a publish token is retired.

  • Every package's Publishing access is set to "Require 2FA and disallow tokens."

  • Developer/publish teams are read-only on npm; write access is limited to org owners with hardware 2FA.

  • The temporary bulk-hardening token has been deleted.

If any box is unchecked, you still have a stealable credential. Rotate it? No. Delete it.

References

Sign up for our newsletter

By registering for our newsletter, you consent to receive updates from us. Please review our privacy policy to learn how we handle your data. You can unsubscribe at any time.

Sign up for our newsletter

By registering for our newsletter, you consent to receive updates from us. Please review our privacy policy to learn how we handle your data. You can unsubscribe at any time.

Sign up for our newsletter

By registering for our newsletter, you consent to receive updates from us. Please review our privacy policy to learn how we handle your data. You can unsubscribe at any time.