Stage-Only npm Tokens for Agents: What They Close and What They Don’t
An npm stage-only token blocks an agent's direct publish but can still move dist-tags and deprecate versions. Token classes, a drill, alerts, a 2FA review.
Go deeper. Build your own.
Since Sep 18, npm rejects npm publish from a token that is only allowed to stage, even one configured to bypass 2FA for automation. That is the whole of the new npm stage-only token: the lane holding it can put a version in a waiting room, and only a maintainer with a second factor can let it out. For any agent that touches a release, that is a genuine fix, and it closes exactly one door.
The same short changelog names two doors it leaves open. A stage-only token can still move dist-tags and deprecate versions, and GitHub tells you to guard it like any other write token. Add the credentials it never touched, the bypass-2FA tokens already issued and the GitHub OAuth token in a developer’s CLI login, and you have the real shape of the problem.
The move for Tuesday: every lane that can reach the registry gets a token class that matches its job, and none of them gets publish. You write down what the stage class can still do, prove with the agent’s own token that a direct publish fails, alert on every publish, dist-tag move and deprecation in your namespace, and strip long-lived human credentials off agent hosts. The approval stays a person comparing a staged tarball to a CI build. Where the pipeline supports trusted publishing, the long-lived token goes away entirely.
Sep 18: the npm stage-only token splits stage from publish
On Sep 18, 2026, GitHub’s changelog added a new permission level for npm granular access tokens, “Read and write (stage only)”. The workflow runs npm stage publish, a maintainer approves the release with 2FA, and “npm rejects direct npm publish attempts with that token, even if you’ve configured it to bypass 2FA for automation.” You need publish access to the package, 2FA on the account, npm CLI 11.15.0 or later and Node.js 22.14.0 or later. The release is opt-in and “doesn’t change existing tokens or their direct-publish capabilities.”
The lines that matter most sit in the middle of the post: “Stage-only tokens retain other package write permissions, including moving dist-tags and deprecating versions. Protect them with the same care as any other write token.” Further down comes a date: npm “is targeting January 2027 to remove direct publishing through bypass-2FA tokens”, and it points to npm’s roadmap discussion for the wider plan.
Screenshot: GitHub Changelog, “Stage-only npm tokens for safer automation” (Sep 18, 2026), captured Sep 21, 2026.
Staging itself is older. GitHub’s changelog announced staged publishing and new install-time controls for npm on May 22 and multiple trusted-publishing configurations on Sep 3. npm’s staged-publishing docs supply the mechanics: npm stage publish “does not require 2FA”, npm stage list, npm stage view and npm stage download show you what is waiting, and approval asks for a second factor “whether you approve the package in the CLI or on npmjs.com.”
Two days before the tokens shipped, The Hacker News carried Mandiant’s account of a hijacked AI coding session at an unnamed software-as-a-service provider. The Mandiant report, dated September 2026, is blunt about the chain: “Once the recommendation was accepted, the attacker used the developer’s active session to install an infostealer using a poisoned PyPI package, harvest GitHub OAuth tokens, and deploy the self-propagating Shai-Hulud worm across approximately 100 internal code repositories.” The attacker then poisoned a package in the company’s official namespace, and a second employee pulled it.
Screenshot: Google Cloud, “Mandiant AI Risk and Resilience Report 2026” (September 2026), captured Sep 21, 2026.
Be precise about what that case shows. The entry vector was a poisoned PyPI package, so a stage-only npm token is not a counterfactual for it, and Mandiant’s report never mentions npm, publish tokens or staged publishing. It names neither the assistant nor the registry behind the poisoned official-namespace package. What the case does show is the part this runbook covers: a hijacked session inherits whatever credentials its host holds, and the second infection came out of the victim’s own namespace.
An agent holding a publish token is a release nobody watches
A publish token in a chatbot’s world is a secret someone might paste into a prompt. In an agent’s world it is a capability the lane exercises on its own schedule: the lane decides the version is ready, runs the command, and every downstream install picks it up. Hijack the session, or poison something the session trusts, and the attacker inherits the capability with the same timing.
Stage-only tokens move the irreversible step out of the lane and onto a person with a second factor. They don’t make the token read-only, and they do nothing about credentials the lane was never supposed to hold. The runbook below covers both halves.
Step 1: Give every lane a token class, and never the publish class
Four classes cover most fleets. Agents get three of them.
| Class | Typical lanes | npm credential | GitHub credential | Can it make a version installable? |
|---|---|---|---|---|
| Read | dependency audits, test runs, codegen that installs packages | none for public packages; a read-only token for private ones | read-only, the lane’s own identity | No |
| PR | coding agents that bump versions, edit changelogs, open release PRs | none | the lane’s own App or fine-grained token, scoped to branches and PRs | No |
| Stage | the release job an agent runs or triggers | “Read and write (stage only)”, scoped to the packages that job releases | only what the release job needs | No: a human approves with 2FA |
| Publish | nobody in the fleet | a maintainer’s own account with 2FA | not applicable | Yes |
Scope every stage token to the packages that lane actually releases, and give it the shortest expiry your rotation can live with. The GitHub side of each class, one App or service principal per lane with short-lived tokens, is covered in the service-principal playbook for agent identity; broker patterns are their own discipline and out of scope here.
Before you issue a single stage-only token, check the floors in the lane’s real runtime, not on your laptop: npm CLI 11.15.0 or later, Node.js 22.14.0 or later, publish access for the account that owns the token, and 2FA enabled on that account. A lane that fails the preflight must fail loudly. The dangerous version is a helpful release script that notices staging is unavailable and reaches for the old automation token.
Then write down what the stage class can still do. The changelog says “including”, so its list is a floor, not an inventory. Give the register three states: documented, rejected, and test it.
| Action with the stage-only token | Status | Source | Your control |
|---|---|---|---|
npm stage publish |
allowed, the point of the class | changelog, npm docs | CI build record per stage (Step 5) |
direct npm publish |
rejected, even with bypass-2FA set | changelog | the drill in Step 2 |
| move a dist-tag | allowed | changelog | namespace alert (Step 3) |
| deprecate a version | allowed | changelog | namespace alert (Step 3) |
| approve a staged version | approval prompts for 2FA | npm docs | the approver is a person (Step 5) |
npm unpublish |
not documented | test it | drill result, logged here |
npm access or npm owner changes |
not documented | test it | drill result, logged here |
Keep the register beside the token inventory and re-run it whenever npm changes the permission model. It is the first page an incident responder should open.
Step 2: Prove direct publish fails with the agent’s own token
The rejection is npm’s claim. The token under test is yours. Run the drill from the lane’s runtime, with the exact credential the lane receives in production, against a scratch package in your scope that nothing depends on.
# publish-drill.sh: illustrative shape. Run inside the agent lane's runtime,
# with the lane's own token, against a scratch package nobody installs.
# Setup, once, by a maintainer: publish $PKG@0.0.1 so the residual checks have a target.
PKG="@yourscope/publish-drill"
npm --version # expect 11.15.0 or later
node --version # expect v22.14.0 or later
npm whoami # expect the lane's account, never a person's
npm publish; echo "direct publish exit: $?" # expect non-zero
npm stage publish; echo "stage publish exit: $?" # expect 0 and a stage ID
npm stage list "$PKG" # expect the staged version
npm dist-tag add "$PKG@0.0.1" drill; echo "dist-tag exit: $?" # documented: allowed
npm deprecate "$PKG@0.0.1" "publish drill"; echo "deprecate exit: $?" # documented: allowed
npm unpublish "$PKG@0.0.1"; echo "unpublish exit: $?" # undocumented: record it
Score it against a fixed table, and keep every run’s output with the date and the token’s ID.
| Check | Pass | A fail means |
|---|---|---|
npm whoami |
the lane’s own account | a human credential is on the lane (Step 4) |
| direct publish | non-zero exit, no new version in the registry | the lane can reach a token with direct publish; find it before anything else |
| stage publish | a stage ID, version not installable | staging is broken; fix it, never fall back |
| dist-tag and deprecate | commands succeed and your alert fires inside one polling interval | the watcher is blind (Step 3) |
| unpublish, owner, access | outcome recorded either way | the register is out of date |
Run the drill on every token rotation, every npm bump in the lane image, and every edit to the release job. It proves one token and says nothing about the others in reach, so pair it with a sweep of the lane’s environment variables, config files and mounted secrets for any other npm credential. The stage-only release left existing tokens untouched, which means an older bypass-2FA automation token is probably still valid somewhere.
Step 3: Alert on every publish, dist-tag move and deprecation in your namespace
The direct-publish rejection comes with the token’s permission level, not from a hook on the agent’s host that a hijacked session could skip. Nothing blocks the residual rights, so the control there is detection. Watch the whole namespace, not only the packages agents release: in the Mandiant case the second infection came from the company’s own namespace.
| Event | Expected when | Alert when | Severity |
|---|---|---|---|
| new version appears | a human approved a stage ID tied to a CI build record | no matching approval or build record | page |
latest dist-tag moves |
inside a release window, to the version just approved | outside a window, or pointing at an older version | page |
| any other dist-tag moves | the release job, during a release | no release in flight | ticket |
| a version is deprecated | a maintainer’s recorded decision | no person attached to it | page |
| a staged version appears | the release job ran | no job run matches | ticket |
| the watcher misses a poll | never | two polls in a row | page |
The dist-tag and deprecation rows page for a reason. The latest tag decides what a plain install resolves, so moving it back to an older release you have since patched is a downgrade nobody approved. A deprecation message is text every installer of that version sees, which makes an unexpected one a phishing channel with your package’s name on it. Both are scenarios rather than events from the case, and both sit inside what the changelog says a stage-only token can do.
# namespace-watch.sh: illustrative shape. Run on a schedule from a host
# that holds no npm write credential of any kind. notify and heartbeat
# stand for whatever your alerting exposes. Note "$pkg@*" resolves to latest
# only; ">=0" prints one line per deprecated release version (not prereleases).
while read -r pkg; do
key=$(echo "$pkg" | tr '/@' '__')
npm view "$pkg" dist-tags --json > "state/$key.tags.new"
npm view "$pkg" time --json > "state/$key.time.new"
npm view "$pkg@>=0" deprecated > "state/$key.dep.new"
for kind in tags time dep; do
cmp -s "state/$key.$kind" "state/$key.$kind.new" || notify "npm $kind changed: $pkg"
mv "state/$key.$kind.new" "state/$key.$kind"
done
done < namespace-packages.txt
heartbeat npm-namespace-watch
The watcher is detection, and its polling interval is your exposure window: on a five-minute poll, a moved tag can serve five minutes of installs before anyone knows. Run it from a host with no npm write credentials, alert on its own missed heartbeats, and keep a slower second check on a different host. If the watcher dies quietly, you are back to trusting the token.
Step 4: Take long-lived human credentials off every agent host
Mandiant’s recommended controls include this one: “Isolate local credentials to prevent extensions from accessing raw API keys, long-lived OAuth token, or secrets”. In the case, the attacker worked through the developer’s active session and harvested GitHub OAuth tokens. The fleet version is a hard rule: agents never hold a person’s long-lived credentials.
| Credential | Where it hides on an agent host | Replace it with |
|---|---|---|
| GitHub OAuth token from a person’s CLI login | the CLI’s credential store under the home directory the agent runs as | the lane’s own App or service-principal token |
| a person’s PAT, classic or fine-grained | environment variables, shell profiles, git credential helpers, .env files |
a per-lane identity with its own scope |
| bypass-2FA npm publish token | .npmrc files, CI secrets the lane can read, environment variables |
a stage-only token, or trusted publishing (Step 6) |
| a person’s npm login session | .npmrc in that person’s home directory |
nothing; the person approves from their own device |
Sweep for them by value and by location on every host an agent runs on, including developer workstations where an assistant shares the developer’s session. That last category is the awkward one, because the Mandiant chain ran in exactly that kind of session. You can’t take the developer’s GitHub login away from the developer. You can keep publish tokens and bypass-2FA tokens off that machine entirely, so a hijacked session there finds stage rights at most, and usually nothing.
Don’t wait for January 2027 to retire the old tokens. Inventory every bypass-2FA token now, replace each with a stage-only token or trusted publishing, and revoke the old one the day its replacement passes Step 2. The target date is npm’s stated plan, and a plan can slip; a revoked token can’t.
Every date is from GitHub’s changelog or The Hacker News. January 2027 is npm’s stated target, drawn as a month because no day is given.
Step 5: The approver checks the staged tarball against the CI build, then approves
The human step is the whole security value of staging, and it is easy to hollow out. An approver who clicks through because a chat message said the release was ready is a bypass-2FA token with extra steps. Give the approval a five-minute checklist and a record that proves each check happened.
- The CI job that stages the version records the tarball’s SHA-256, the commit SHA and the build ID in the release ticket before it runs
npm stage publish. - The approver runs
npm stage listandnpm stage viewon the stage ID and confirms the package name and version match the ticket. npm also keeps a separatenpm stagecommand reference. - The approver downloads the staged tarball with
npm stage download, hashes it, and compares the hash with the CI record. If the hashes differ, unpack both and diff them: a difference only in the compressed bytes is noise, a changed file is a stop. - The approver diffs the staged package against the last published version, looking for new install scripts, new dependencies, new binaries and changed entry points. Any of those without a matching reviewed PR is a stop.
- Only then does the approver run
npm stage approveor use the Staged Packages tab on npmjs.com, with 2FA from their own device.
# approve-check.sh: illustrative. The human approver runs this; no lane ever does.
npm stage view "$STAGE_ID" # name and version match the ticket?
npm stage download "$STAGE_ID" # fetch the staged tarball
sha256sum ./<downloaded-tarball>.tgz # equal to the SHA-256 in the CI build record?
mkdir -p staged prev
tar -xzf ./<downloaded-tarball>.tgz -C staged
(cd prev && npm pack "$PKG@$PREV_VERSION" && tar -xzf ./*.tgz)
diff -r prev/package staged/package # install scripts, dependencies, binaries?
# every check passed: approve with your own second factor
npm stage approve "$STAGE_ID"
The amber path is the one that skips the human. The dashed boxes are the controls you add.
What the docs leave out matters here. npm’s staged-publishing docs do not say whether a staged version expires, and they document no reject or discard command. A version that fails your check doesn’t vanish; it sits in the list, where a tired approver could approve it next week. Record the failed stage ID in the ticket, alert on stage IDs older than a day, and treat an approval of a stage ID marked failed as an incident.
Keep the approval human. No lane holds a 2FA device or a TOTP seed, no script wraps npm stage approve, and when you have two maintainers, the approver is not the person whose session started the release.
Step 6: Prefer trusted publishing where the pipeline supports it
The changelog frames stage-only tokens as a bridge: “If you can’t move to trusted publishing yet, stage-only tokens offer a migration path for token-based automation.” Trusted publishing replaces the long-lived npm token with an identity the CI provider asserts for each run, so there is no token on disk to steal. npm’s staged-publishing docs say staging also works with trusted publishing (OIDC), which gives you both properties at once: no long-lived secret, and a person still approves every version.
| Pipeline | Posture |
|---|---|
| a CI provider npm’s trusted publishing supports | trusted publishing plus staging; no npm token exists anywhere |
| a supported provider, but the job also releases for humans | the same; approval is per version either way |
| a provider trusted publishing doesn’t support | stage-only token, shortest expiry, Steps 2 to 5 in full |
| a developer workstation | no automation token at all; the developer stages or publishes interactively with 2FA |
Check whether the Sep 3 multiple-configurations change covers the workflow layout that made you keep a shared token. And if you run trusted publishing without staging, whatever can start the publishing workflow can publish, so the trigger surface becomes the credential. That is where the GitHub Actions trigger allowlist for agent identities earns its keep, because an agent that can’t publish can often still start the job that does.
Where an npm stage-only token leaks anyway, and the signal for each
The old token nobody revoked. Signal: a version in your namespace with no stage ID and no approval record behind it. Fix: find the token with the Step 4 sweep, revoke it, and rerun the Step 2 drill on every lane that could reach it.
Residual rights, used. Signal: a Step 3 page for a latest move or a deprecation outside a release window. Response: restore the tag from a clean host, revoke the stage-only token, and read the lane’s session record before issuing a new one.
The rubber-stamp approval. Signal: approvals landing seconds after staging, or release tickets with no hash recorded. Fix: an approval without the Step 5 record doesn’t count, and the release job refuses to proceed without it.
Staged versions piling up. Signal: stage IDs older than a day in npm stage list. Response: record each one against its ticket. The docs do not say whether staged versions expire, so treat an old one as live.
The silent fallback. Signal: a release that succeeded on a lane whose preflight failed. Fix: remove the old token from the lane image entirely; a lane without a publish token has nothing to fall back to.
The upstream this runbook doesn’t touch. Signal: none from anything above. The Mandiant chain began with a poisoned dependency the assistant recommended, and stage-only tokens do nothing there. Mandiant’s controls for that link are checksum and allowlist verification of AI-recommended dependencies, plus routing dependency traffic through internal repositories. Hooks a package writes after install are a persistence problem with their own runbook, and a lane that reads untrusted input while holding stage rights is a candidate for the Rule of Two lane split.
Publish rights belong in the fleet inventory, not a CI settings page
Every control above lives outside the model and outside the registry’s defaults: the token-class table, the register, the drill on a timer, the watcher on a host with no write rights, the ticket that carries a hash. That is the operating layer, the same place that already holds the kill switches and approval queues for the rest of the fleet; running a fleet of agents is mostly the work of keeping that layer honest. The overnight merge gates decide what an agent may land in your repository. The publish path decides what everyone else installs, and it deserves at least the same care.
Stage-only tokens are a real improvement, and I’d switch every token-based release lane to them this week. Just write down the two doors they leave open, and watch those doors from somewhere the token can’t reach.
FAQ
Does an npm stage-only token stop a hijacked agent from publishing?
It stops a direct npm publish with that token: npm rejects it even when the token is set to bypass 2FA, and the version waits until a maintainer approves with 2FA. The same token can still move dist-tags and deprecate versions, and older publish tokens in reach keep working.
Does npm staged publishing work with trusted publishing?
npm’s staged-publishing docs say it does, and the pairing is the strongest option for agent-driven releases. The pipeline authenticates per run through OIDC, so no long-lived npm token exists to steal, and every version still waits for a person’s 2FA approval before anyone can install it.
Sources
- GitHub changelog: Stage-only npm tokens for safer automation — Sep 18, 2026; the permission level, bypass-2FA rejection, residual rights, version floors, January 2027 target
- npm docs: Staged publishing —
npm stage publishneeds no 2FA; list, view, download, approve; works with trusted publishing - npm docs: npm stage command reference — CLI reference page for
npm stage - npm docs: Trusted publishers — the OIDC publishing path the changelog recommends
- GitHub changelog: Staged publishing and new install-time controls for npm — May 22, 2026
- GitHub changelog: Multiple trusted publishing configurations for npm — Sep 3, 2026
- GitHub community: npm’s roadmap — safer publishing roadmap linked from the Sep 18 changelog
- Mandiant AI Risk and Resilience Report 2026 — September 2026; case study 1, the PyPI entry vector and the credential-isolation control
- The Hacker News: Attacker Hijacks AI Coding Assistant Session, Spreads Shai-Hulud Across About 100 Repositories — Sep 16, 2026
