npm staged publishing: 2FA-gated releases and --allow-* controls in CLI 11.15.0
Contents
TL;DR
What changed npm CLI 11.15.0 makes staged publishing GA: npm stage publish puts a tarball in a stage queue; a maintainer approves with 2FA via the CLI or npmjs.com before it reaches the registry
Requirements an existing package, publish rights, account 2FA, npm CLI 11.15.0+, and Node 22.14.0+. The first-time publish of a brand-new package is not eligible for staged publishing
Install controls --allow-file, --allow-remote, and --allow-directory were added; together with the existing --allow-git you can explicitly close off non-registry sources. The current default is all
npm’s defenses are splitting into human approval before publish and source restrictions at install time.
The GitHub Changelog announcement on May 22, 2026 made staged publishing GA from npm CLI 11.15.0 onward, and at the same time added --allow-file, --allow-remote, and --allow-directory.
The release-age gate in pnpm 11, Yarn 4.10, and npm v11.10 I wrote about earlier was about the consumer side not pulling a freshly published malicious version right away.
Staged publishing sits on the opposite side: it’s a mechanism for the package author to stop something just before publish.
CI gets to the stage queue, a human pushes it to the registry with 2FA
With the traditional npm publish, once authentication passes the package goes live on the spot.
With staged publishing, running npm stage publish from CI or locally puts the built tarball into a stage queue.
At that point it’s still invisible to a consumer’s npm install.
To publish, a maintainer reviews the staged package and approves it with npm stage approve <stage-id> or the Approve action on npmjs.com.
2FA is required at this approval step.
The docs separate the two: npm stage publish itself does not need 2FA, while the approve side enforces the 2FA check.
The flow looks like this.
flowchart TD
A["CI / local<br/>npm stage publish"] --> B["stage queue<br/>not installable yet"]
B --> C["npm stage view / download<br/>inspect tarball"]
C --> D["maintainer approve<br/>2FA challenge"]
D --> E["publish to npm registry<br/>installable"]
There are conditions too.
Staged publishing is only available for a package that already exists on the npm registry.
You can’t stage the first-time publish of a new package.
On top of that, you need publish rights, account 2FA, npm CLI 11.15.0+, and Node 22.14.0+.
This constraint isn’t trivial.
Typosquatting on a first-time publish (the trick of naming a fake package to resemble a real one) and attacks that create a new scope and throw something into it aren’t stopped by staged publishing alone.
It’s a feature that adds a pre-publish approval point to the update flow of an existing package.
Trusted publishing can be narrowed to stage-only
GitHub recommends combining staged publishing with trusted publishing.
Trusted publishing is the mechanism for publishing to npm over OIDC from CI such as GitHub Actions.
It removes the need to keep a long-lived npm token in CI secrets.
The new wrinkle is that you can restrict a trusted publisher’s allowed action to npm stage publish only.
With this setting, even an attempt to run npm publish directly from that CI workflow is rejected, and only pushing to the stage queue is allowed.
CI stays non-interactive and proceeds through build and stage submission, while only the final registry publish is moved back to a human approval with 2FA.
This ties into the OIDC abuse seen in Mini Shai-Hulud’s TanStack/Mistral wave.
In the TanStack wave, a malicious package with SLSA provenance came out through a legitimate GitHub Actions path.
Provenance shows “which CI built it,” but it doesn’t prove that the runner or cache wasn’t tainted.
With a stage-only trusted publisher, even if a short-lived publish token is stolen on CI, it stops at the stage queue before the final publish.
Of course, if the approver hits Approve without reviewing the tarball, it goes through.
Even so, compared to a path where “as soon as CI succeeds it publishes straight to the registry,” it adds one more place to look.
What you review in the stage queue is the diff and the source
Staged publishing is weak if all it does is add an approval screen.
The workflow is to review the tarball in the stage queue with npm stage view or npm stage download and, at minimum, check the following diffs.
| What to look at | Why |
|---|---|
scripts in package.json | Catch additions of preinstall, install, postinstall, prepare |
| dependency diff | Catch fake dependencies like Mastra’s easy-day-js or axios’s plain-crypto-js |
| provenance | Distinguish legitimate CI origin from a local or long-lived-token publish |
| new files in the tarball | Catch setup.cjs, loaders, obfuscated JavaScript |
In the npm compromise of Mastra, the entry point was not the legitimate package itself but the postinstall of an added easy-day-js dependency.
Looking only at the code diff of Mastra’s own package won’t surface the malicious code itself.
That’s the point of checking the dependencies and the tarball in the stage queue.
That said, since staged publishing relies on 2FA approval by the maintainer account, it can’t help if the approver’s device is compromised.
It also won’t automatically stop malicious changes that go unnoticed for a long time, or small dependency additions that slip past review.
Don’t settle for “a human approves it”; decide in advance what diffs you’re going to look at.
Closing off non-registry sources with allow flags
In the same npm CLI 11.15.0, three flags were added on the install side.
| Flag | What it closes off |
|---|---|
--allow-file | local file paths, local tarballs |
--allow-remote | remote tarballs like https://.../package.tgz |
--allow-directory | local directories |
--allow-git | Git sources such as github:, gitlab:, git+, and owner/repo forms |
The value is all or none.
You can also set it in .npmrc or in package.json config.
Right now both the three new ones and the existing --allow-git default to all, so projects that want to close them off need to set it explicitly.
# .npmrc
allow-git=none
allow-remote=none
allow-file=none
allow-directory=none
The GitHub Changelog notes that for --allow-git, the default is planned to change to none in npm v12.
The new --allow-file, --allow-remote, and --allow-directory are additions, as of 11.15.0, for people who want to tighten things ahead of time.
Git dependencies became an attack path directly in Mini Shai-Hulud’s @antv wave.
An optional dependency like github:antvis/G2#... is fetched separately from the npm tarball, and the lifecycle script on the Git-dependency side runs.
The release-age gate looks at the publish time on the npm registry, but a Git commit has no npm publish time.
allow-git=none stops that separate path before dependency resolution.
Remote tarballs are similar.
They tend to fall outside registry version history, unpublish, deprecate, and provenance display.
In a project where a dependency like https://example.com/pkg.tgz lands in the lockfile, it’s safer not to allow it without review.
A different layer from the age gate and allowScripts
Since npm defenses piled up from May into June, it’s better not to mix up where each one applies.
| Mechanism | Where it stops things | Representative example |
|---|---|---|
| staged publishing | before registry publish | CI puts it in the stage queue, a human approves with 2FA |
| trusted publishing | publish authentication | removes long-lived npm tokens from CI |
| provenance | evidence of the publish path | see which workflow built it |
| release-age gate | dependency resolution | don’t pull a freshly tainted version |
| allow source flags | dependency source | close off Git, remote tarballs, file, directory |
| allowScripts | execution at install | put postinstall and implicit node-gyp behind an allowlist |
The install scripts default-off in npm v12 was about stopping the automatic execution of already-fetched packages.
This time’s --allow-* narrows the source itself.
Staged publishing enters even earlier, in the publish flow.
Take the Mastra wave, for example: not pulling easy-day-js@1.11.22 right after publish is the release-age gate, not running its postinstall even if you do pull it is allowScripts, and having room to stop Mastra’s legitimate update before publish is staged publishing.
Same incident, different places where each applies.
What an existing package maintainer changes
If you publish an npm package, first change your CI’s publish step from npm publish to npm stage publish.
If you use a trusted publisher, restrict the allowed action to npm stage publish only.
If your existing config was created before May 20, 2026, npm docs explain that, to avoid changing the behavior of existing workflows, it’s treated as allowing npm publish only.
Review old trusted publisher settings explicitly.
There are examples already in motion.
The npm account ai that publishes postcss and nanoid, after being flagged for concentrated risk from not setting provenance, is moving toward staged releases that insert approval before publish.
On the consumer side, you can try out the setting to close off non-registry sources on npm 11.15.0+ ahead of time.
Especially for projects that do a clean install on every CI run, check that the lockfile doesn’t mix in github:, git+, or https://...tgz before adding allow-git=none and allow-remote=none.
In a monorepo that legitimately uses file: or out-of-workspace directories, setting everything to none outright will break the dev flow.
Even then, you can split it up: close it off only in CI, or only in release builds.