
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 | Malware stole a 2FA SSO session, and the attacker exfiltrated customer env vars, including stored | 1: CI-resident token | OIDC Trusted Publishing | |
2025 | Shai-Hulud worm | Self-replicating package harvested | 1: CI-resident token | OIDC Trusted Publishing |
2026 | 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:
CircleCI issues a signed JWT identifying the workflow, job, and branch running.
The npm registry validates issuer, subject, and audience against the Trusted Publisher configuration on the package.
On match, npm mints a short-lived token scoped to that one package.
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:
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:
Create a CircleCI context with a branch restriction (only
maincan access it).Bind the publish job to that context.
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:
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_TOKENin 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)
Add the Trusted Publisher on npm (package Settings → Publishing access → CircleCI).
Upgrade the publish job (npm >= 11.5.1, Node >= 22.14.0 for us) and swap
NPM_TOKENsetup forcircleci run oidc get.Run a release. Confirm the publish succeeds and the build no longer reads
NPM_TOKEN.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.
Set every package to "Require 2FA and disallow tokens." Makes static-token publishes impossible at the registry level.
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.
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
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.
Run the loop:
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_TOKENis 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
npm Trusted Publishing now supports CircleCI: announcement.
ethereum-optimism/ecosystemCircleCI config: reference implementation. Original: PR #992 (needed follow-up fixes).Google Cloud Threat Intelligence: North Korea threat actor targets
axios.