npm Publish Account Hardening: Lessons from the Axios Maintainer Compromise
The Problem
npm’s Trusted Publishing feature was designed to close the long-lived token problem: instead of storing a persistent NPM_TOKEN secret in CI, a GitHub Actions workflow requests a short-lived OIDC credential from GitHub, presents it to the npm registry, and the registry issues a publish token scoped to a single run. On March 31 2026, the North Korean threat actor Sapphire Sleet compromised the Axios supply chain and bypassed this protection entirely by never touching CI at all. The attacker used malware on a maintainer’s PC to steal the maintainer’s personal npm authentication token, then ran npm publish directly from an arbitrary machine. The npm registry accepted the publish as legitimate, and malicious versions 1.14.1 and 0.30.0 were distributed to Axios’s millions of downstream consumers before the package was yanked.
The gap in npm’s security model is a parallel publish path that Trusted Publishing does not eliminate. OIDC-based Trusted Publishing is a publish-path restriction: it requires the publish request to arrive from a GitHub Actions job that matches the configured workflow reference, repository, and environment. When an attacker publishes using a token that was not issued by OIDC — a personal access token generated months earlier by a human on their own account page — the registry has no OIDC context to validate. The Trusted Publisher configuration on the Axios package prevented unauthorised new OIDC publishers from being added. It did nothing to block a token-authenticated publish that bypassed OIDC entirely. Personal npm tokens remain a parallel publish path that maintainers retain for local development, and npm does not block token-authenticated publishes on packages that have Trusted Publishing configured unless every other path is explicitly closed.
The Axios incident is not a failure of OIDC Trusted Publishing as a concept. It is a failure to combine it with the account-level controls that make the token path unusable for an attacker: hardware-key 2FA on maintainer npm accounts, scoped automation tokens with CIDR restrictions in CI, revocation of personal tokens after migration to OIDC, provenance attestations as a publish integrity signal, and a detection layer that catches publishes which did not originate from the expected CI pipeline.
Threat Model
-
Stolen maintainer npm token via PC malware, phishing, or credential dump. The attacker has a full-access personal token generated by the maintainer’s own npm account. The Axios attack used this exact path: malware on the maintainer’s machine exfiltrated the token from local npm credential storage (
~/.npmrc). With the token, the attacker can publish any version of any package the maintainer has write access to, from any machine, without triggering any CI pipeline. -
Compromised maintainer PC running
npm publishlocally. Even without an explicit token exfiltration step, an attacker who has persistent access to a maintainer’s machine can runnpm publishin the context of the maintainer’s authenticated npm session. This bypasses GitHub Actions OIDC, branch protection rules, required reviewers, and every other CI-based control. -
Overprivileged npm automation token leaked from CI secrets. If the CI secret is a full-access automation token rather than a package-scoped token, and if that secret is exfiltrated by a compromised build dependency or a vulnerable Actions step, the attacker can publish to any package the originating account owns. The blast radius is not limited to the single package the CI workflow was publishing.
-
latestdist-tag as the highest-value target. Publishing a new version is not sufficient for widespread compromise unless that version becomes thelatestdist-tag. All users runningnpm install axioswithout an explicit version pin resolve tolatest. The Axios attacker published 1.14.1, which would naturally supersede 1.14.0 aslatest. Compromisinglateston a package with tens of millions of weekly downloads achieves immediate, broad distribution. -
npm account without 2FA. If a maintainer account has 2FA disabled or uses only TOTP (which can be phished), token theft is sufficient for a publish. The attacker does not need interactive access to the account — the token carries the publish authority on its own. An account without hardware-key 2FA on publish operations provides no second factor that the token cannot substitute for.
Hardening Configuration
1. Hardware-key 2FA for npm accounts
Enrol every package maintainer’s npm account in 2FA using a FIDO2 hardware key (YubiKey, Google Titan, etc.) rather than a TOTP authenticator app. TOTP codes can be phished in real time; FIDO2 hardware keys are origin-bound and cannot be phished remotely.
npm profile enable-2fa auth-and-writes
After enabling 2FA at the account level, enforce it at the package level so that publish operations require a second factor even when initiated with a token:
npm access 2fa-required axios
Verify the current 2FA requirement status for a package:
npm access get status axios
With --require-2fa active on a package, a publish attempt using a token alone returns an error prompting for a one-time password. An attacker with a stolen token but without the maintainer’s hardware key cannot complete the publish. This is the single highest-value control available for npm maintainer account security.
2. Scoped automation tokens with CIDR allowlists
Replace any personal npm tokens used in CI with automation tokens scoped to a specific package. Automation tokens cannot be used for interactive npm login and cannot be used to modify account settings or add collaborators. They are publish-only credentials.
npm token create \
--type=automation \
--cidr-allowlist=10.0.0.0/8,192.168.1.100/32
The --cidr-allowlist flag restricts the token to a set of source IP ranges. A token stolen from CI secrets becomes useless if the attacker is not connecting from an IP within the allowlist. For self-hosted runners with a fixed NAT IP address, set the allowlist to exactly that IP. For self-hosted runners behind a NAT gateway:
npm token create \
--type=automation \
--cidr-allowlist=203.0.113.45/32
List active tokens to audit what is currently in use:
npm token list
Revoke any token that does not have a documented purpose and owner:
npm token revoke <token-id>
After migrating to OIDC Trusted Publishing (section 3 below), revoke all automation tokens as well. The goal is to reach a state where there are no active npm tokens at all for the package — only OIDC-issued credentials that expire within minutes.
3. npm Trusted Publishing (OIDC) and revoking the token path
Configure GitHub Actions as the Trusted Publisher for the package in the npm registry. Under the package’s settings on npmjs.com, navigate to Publishing access and add a Trusted Publisher specifying the GitHub repository, workflow filename, and optionally the Actions environment name.
The publish workflow must request id-token: write permission and run npm publish --provenance:
name: Publish to npm
on:
push:
tags:
- "v*.*.*"
jobs:
publish:
runs-on: ubuntu-latest
environment:
name: npm-publish
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- uses: actions/setup-node@v4
with:
node-version: "20"
registry-url: "https://registry.npmjs.org"
- name: Install dependencies
run: npm ci
- name: Build
run: npm run build
- name: Publish with provenance
run: npm publish --provenance --access public
No NPM_TOKEN secret is set in this workflow. The id-token: write permission causes npm to negotiate an OIDC credential with the registry directly. After confirming a successful publish via this workflow, delete the NPM_TOKEN GitHub Actions secret and revoke any remaining personal or automation tokens in the npm UI. Leaving old tokens active after OIDC migration is the most common post-migration failure mode and was the gap that the Axios attacker exploited.
4. Out-of-band publish detection
npm does not natively send email or webhook notifications when a new version of a package is published. Build an independent detection layer: a scheduled GitHub Actions workflow that polls the npm registry every 15 minutes and alerts when a version appears that was not published by the expected CI pipeline.
name: Detect out-of-band publishes
on:
schedule:
- cron: "*/15 * * * *"
workflow_dispatch:
jobs:
detect:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11
- name: Fetch current version list
id: fetch
run: |
curl -sf https://registry.npmjs.org/axios \
| jq -r '.versions | keys | sort | .[]' \
> /tmp/current-versions.txt
- name: Compare against known-good baseline
run: |
diff .github/known-good-versions.txt /tmp/current-versions.txt \
> /tmp/version-diff.txt || true
NEW_VERSIONS=$(grep '^>' /tmp/version-diff.txt | wc -l)
if [ "$NEW_VERSIONS" -gt "0" ]; then
echo "ALERT: new versions detected outside CI"
grep '^>' /tmp/version-diff.txt
exit 1
fi
The known-good-versions.txt file in the repository is updated by the CI publish workflow after each successful publish. The detection workflow compares the live registry version list against the committed baseline. A version that appears in the registry but was not added to the baseline by CI triggers a non-zero exit code, which fails the workflow and generates a GitHub Actions notification.
Update the baseline as part of the publish workflow:
curl -sf https://registry.npmjs.org/axios \
| jq -r '.versions | keys | sort | .[]' \
> .github/known-good-versions.txt
git add .github/known-good-versions.txt
git commit -m "chore: update known-good versions baseline"
git push
The Axios attack lasted approximately three hours before remediation began. A 15-minute polling interval would have triggered an alert within 15 minutes of the malicious publish, reducing attacker dwell time from hours to minutes.
5. Provenance attestations as an integrity signal
npm provenance (GA in 2024) records the GitHub Actions run that published the package and links it to the source commit via a Sigstore transparency log entry. The attestation is embedded in the npm registry and is visible to consumers.
Verify provenance on any installed package:
npm audit signatures axios
A package published with provenance shows output similar to:
audited 1 package in 2s
1 package has a verified attestation
axios@1.14.0: Verified from https://github.com/axios/axios/actions/runs/14320000
A package published without provenance — such as the malicious Axios versions 1.14.1 and 0.30.0 — would show a missing or unverifiable attestation:
audited 1 package in 2s
1 package has an unverified or missing attestation
axios@1.14.1: No provenance information found
Provenance absence is a detection signal. The Axios malicious versions were published directly via npm publish from outside CI, which means they bypassed the --provenance flag entirely and would have had no Sigstore attestation. Consumers and automated dependency update tools that check npm audit signatures as part of their update pipeline would have detected the missing attestation before installing.
To integrate provenance verification into a CI install step:
npm install --audit
npm audit signatures
Treat a failed npm audit signatures on any direct dependency as a blocking signal requiring human review before the install proceeds to the build step.
6. Incident response: first hour of suspected token compromise
If a maintainer account compromise is suspected — malware detected on a maintainer’s machine, phishing email clicked, unexpected publish notification, or a new version appearing in the out-of-band detection workflow — execute the following sequence immediately.
Revoke all active tokens for the account:
npm token list
npm token revoke <token-id-1>
npm token revoke <token-id-2>
If the malicious version was published within the past 72 hours, unpublish it. npm’s unpublish window is 72 hours from the original publish time:
npm unpublish axios@1.14.1
npm unpublish axios@0.30.0
Move the latest dist-tag to the last known clean version immediately, even before unpublishing. Users running npm install axios without a version pin resolve to latest; updating the dist-tag stops ongoing distribution of the malicious version to new installs:
npm dist-tag add axios@1.14.0 latest
Verify the dist-tag update took effect:
npm dist-tag ls axios
Publish a clean patch version that supersedes the malicious release:
git tag v1.14.2
git push origin v1.14.2
File a GitHub Security Advisory from the repository’s Security tab. The advisory triggers a GitHub advisory database entry, which propagates to npm audit results for downstream consumers. Notify the community via the package’s GitHub Discussions or issue tracker. Rotate any secrets that were accessible on the compromised machine, including SSH keys, cloud credentials, and any other tokens stored in ~/.npmrc or equivalent credential stores.
Expected Behaviour After Hardening
After CIDR-allowlisted automation token: an attacker with the stolen token but connecting from a non-CI IP address receives a 403 Forbidden from the npm registry when attempting npm publish. The token is cryptographically valid but the source IP is outside the allowlist.
After --require-2fa on the package: even with a valid token, npm publish prompts for a one-time password backed by the hardware key. The stolen token alone is insufficient. The attacker would need both the token and physical possession of the hardware key to complete the publish.
After out-of-band publish detection: a version published directly to the npm registry outside of the expected CI workflow triggers an alert within 15 minutes. The detection workflow fails, GitHub sends a notification to the repository’s watchers, and the on-call engineer can begin incident response while the malicious version has been live for at most 15 minutes rather than hours.
After provenance attestations: consumers and automated tooling running npm audit signatures on the published package detect the missing provenance attestation on any version published outside CI. The absence of a Sigstore attestation is a concrete, machine-readable signal that the publish did not follow the expected path.
Trade-offs and Operational Considerations
CIDR allowlists on automation tokens require the CI runner to have a stable, known IP address. GitHub-hosted runners (ubuntu-latest, etc.) use dynamic IP ranges assigned by Azure and rotate frequently — they cannot be reliably allowlisted. This trade-off is real: either switch to self-hosted runners behind a NAT gateway with a fixed egress IP, or accept that the automation token cannot be CIDR-restricted and rely on OIDC Trusted Publishing as the primary control instead. The two approaches are complementary, not redundant — OIDC eliminates the need for a stored token entirely, making the CIDR allowlist question moot for CI publishes.
Enabling --require-2fa on a package affects every maintainer with publish access. A maintainer who has not enrolled a hardware key will be blocked from publishing locally. Coordinate with the full maintainer team before enabling this setting. Provide a hardware key provisioning guide and a grace period for enrollment. Consider requiring hardware-key enrollment as a prerequisite for being added to the maintainer list on any new package going forward.
The out-of-band publish detection workflow at 15-minute intervals creates a dependency on the npm registry API being available and returning consistent results. If the registry is experiencing availability issues, the detection workflow may produce false positives or fail silently. Add a separate health check step that verifies the registry API is responding before comparing version lists, and configure the workflow to skip the comparison (rather than alert) if the registry is unreachable.
Failure Modes
-
Trusted Publishing configured but personal tokens not revoked. This was the exact Axios failure mode. The parallel token path remains fully functional. An attacker with a stolen personal token can publish directly. After configuring OIDC Trusted Publishing, audit and revoke all tokens: personal tokens, automation tokens stored in CI secrets, and any tokens stored in
.npmrcfiles on developer machines. -
CIDR allowlist set to
0.0.0.0/0. An allowlist covering all IP addresses provides no restriction. This configuration is tempting when teams use GitHub-hosted runners with dynamic IPs and want to avoid managing runner IP ranges. The result is a token that appears to have a CIDR restriction configured but is usable from any machine on the internet. Treat0.0.0.0/0as equivalent to no allowlist. -
Out-of-band detection workflow has write access to the production repository. If the detection workflow uses a token with
contents: writepermission, a compromised step in that workflow could suppress alerts by updating theknown-good-versions.txtbaseline to include malicious versions. The detection workflow should havecontents: readonly. Updates to the baseline file must come exclusively from the authenticated publish workflow, not from the detection workflow. -
dist-tagnot updated after incident. A clean version is published, unpublishing removes the malicious version from direct install by version number, butlateststill points to the malicious version. Any user runningnpm install axioswithout a version pin during the remediation window receives the malicious package. Update the dist-tag as the first priority action, before unpublishing and before cutting the clean patch release.