Hardening npm Publishing with Trusted Publishing — Tokenless OIDC, Staged Publishing, and PR-Merge Releases
Plenty of teams still ship to the public npm registry by parking an NPM_TOKEN in GitHub Secrets and running npm publish. Through 2024 that was normal. But after the self-replicating supply-chain attacks that hit the npm ecosystem across 2025 and 2026 (the September 2025 Shai-Hulud worm and its 2026 follow-ups), a long-lived publish token is now treated as exactly what it is: a single point of failure that ends the game the moment it leaks. npm itself has revoked and deprecated classic tokens and pushed CI toward short-lived, per-run credentials.
This post assembles that new default as a recipe that actually runs. The material is the npmjs-public recipe from cli-distribution-recipes, a public repository collecting one recipe per distribution channel, publishing the sample CLI @codenote-net/hello-cli to public npmjs.com. The goal looks like this:
npx @codenote-net/hello-cliOhayou gozaimasu, Konnichiwa, Konbanwa!And the npmjs.com package page shows a “Built and signed on GitHub Actions” provenance attestation linked to this repository and the publish workflow. We achieve all of it without storing any NPM_TOKEN.
- Issue: Add recipes/npmjs-public: publish to npmjs.com via Trusted Publishing (OIDC, no tokens)
- Pull request: Add npmjs Trusted Publishing recipe
- Issue: Add create-release-pr.yml: automate version-bump PRs labeled “Type: Release”
- Pull request: Add automated hello CLI release PR workflow
The design draws on azu’s “Hardening npm publishing (2026)”. This post is that model worked end to end on a single-package repository.
Why Trusted Publishing
Trusted Publishing authenticates npm publishes using GitHub Actions OIDC, and it went generally available in July 2025. The idea in one line: replace a stored secret with a short-lived proof of identity.
The classic npm publish flow hands a long-lived token to CI for authentication. As long as it lives in Secrets it stays valid, and if it leaks into a log, anyone can publish from anywhere. The attacks that modify a workflow to exfiltrate an OIDC token or credentials through logs (the Bitwarden CLI case is a known example) targeted exactly this kind of portable secret.
With Trusted Publishing, GitHub Actions mints a short-lived OIDC token on each publish, and npm checks it against the pre-registered Trusted Publisher conditions (GitHub org, repository, workflow filename, environment name) before allowing the publish. There is no stored secret anywhere. For a public repository and public package, npm also attaches a provenance attestation automatically, making “this package came from this commit of this workflow in this repository” verifiable.
Trusted Publishing is therefore the most basic publish channel, the foundation every other registry recipe (AWS CodeArtifact, Azure Artifacts, Google Cloud Artifact Registry) builds on. Getting secure-by-default right here pays off across the whole repo.
Design philosophy — disarming “one credential” with defense in depth
The principle running through this recipe is defense in depth. The aim is a state where a malicious publish would require an attacker to compromise several independent systems at once.
Concretely, before a live npm package exists in the world, all of the following must be cleared:
- write access to this repository
- an OIDC exchange that matches the Trusted Publisher conditions (workflow file and environment match)
- human approval on a protected GitHub Deployment Environment
- npm MFA approval to promote a staged package to live
The crux is pushing the last two outside CI. As we will see, CI in this recipe can only get a package to “staged.” Live promotion is a separate, MFA-gated npm action that a human performs by hand. So an attacker who rewrites the workflow and seizes repository write access still cannot publish a live package on that alone. Against real attack classes (workflow modification plus OIDC exfiltration, and dependency/publish-timing attacks like the TanStack cache-poisoning case), the goal is to sever the direct path from “repository write” to “live release.”
The big picture
Here is the whole release flow up front.
flowchart TD
A["Maintainer runs create-release-pr"] --> B["Workflow bumps the version"]
B --> C["Open a Type: Release PR from a same-repo branch"]
C --> D["Human review"]
D --> E["Merge PR to main"]
E --> F["publish-hello-cli starts on pull_request.closed"]
F --> G{"Merged and labeled Type: Release?"}
G -- "No" --> H["Job is skipped"]
G -- "Yes" --> I["Request release Environment"]
I --> J["Maintainer approves the Environment deployment"]
J --> K["GitHub OIDC token issued"]
K --> L["npm Trusted Publishing validates repo, workflow, environment"]
L --> M["npm ci"]
M --> N{"Version already on npm?"}
N -- "Yes" --> O["Fail before staging"]
N -- "No" --> P["Verify CLI output"]
P --> Q["npm stage publish"]
Q --> R["Package is staged (not live)"]
R --> S["Maintainer inspects the staged package"]
S --> T{"Approve with npm MFA?"}
T -- "Reject" --> U["npm stage reject"]
T -- "Approve" --> V["npm stage approve"]
V --> W["Package becomes live on npm"]
W --> X["Verify npm view, npx, provenance"]
You can see a row of independent gates running left to right. Let me build out each key one in order.
The npm side — Trusted Publisher and stage-only
First, configure the npm side once. In the @codenote-net/hello-cli package settings on npmjs.com, register Trusted Publishing like this:
Provider: GitHub Actions
Organization or user: codenote-net
Repository: cli-distribution-recipes
Workflow filename: publish-hello-cli.yml
Environment name: release
Allowed actions: npm stage publishThe deliberate move here is to allow only npm stage publish under Allowed actions and to withhold npm publish. This Trusted Publisher can only stage. Even if someone rewrites the workflow and seizes the OIDC exchange, they cannot push a live package on that alone; a separate npm-side staged approval is still required. The final gate of the defense model is baked into the npm configuration itself.
Note that Trusted Publisher configurations created after May 20, 2026 must explicitly select at least one allowed action. We pick npm stage publish.
Next, under Settings → Publishing access, select:
Require two-factor authentication and disallow tokensThis forbids publishing via a long-lived token outright, so publish authority flows only through CI’s OIDC. No NPM_TOKEN secret lives in the repository or the org. If a one-time token is unavoidable for the very first publish, delete it after use and keep standing tokens at zero.
The GitHub side — a protected Environment and the PR merge ref
On the GitHub side, create a Deployment Environment named release and set its protection rules:
- Required reviewers: at least one maintainer, so human approval is mandatory before deploying.
- Allow administrators to bypass configured protection rules: disabled.
- Deployment branches and tags: allow only
refs/pull/*/merge. - The environment name must match both the workflow and the npm Trusted Publisher registration exactly.
That restriction to refs/pull/*/merge is the keystone of the PR-merge release. GitHub evaluates an Environment’s branch protection rules for pull_request events against the executing pull request’s merge ref (refs/pull/<number>/merge). So restricting to refs/pull/*/merge lets only the reviewed-PR-merge path reach the release Environment, while direct pushes, feature branches, and manual dispatches cannot. This is what gives the system its property: even rewriting the workflow itself cannot trigger the publish flow without passing through review and merge.
While you are here, enable “Allow GitHub Actions to create and approve pull requests” under Settings → Actions → General → Workflow permissions for the later release-PR automation, and create the Type: Release label:
gh label create "Type: Release" --color "0e8a16" --description "Release PR"Inside the publish workflow
The publish workflow .github/workflows/publish-hello-cli.yml fires the moment a PR is merged into main (pull_request.closed), then narrows on the job side to “merged and labeled Type: Release.”
on:
pull_request:
types:
- closed
branches:
- main
permissions:
id-token: write
contents: read
jobs:
publish:
if: "${{ github.event.pull_request.merged == true && contains(github.event.pull_request.labels.*.name, 'Type: Release') }}"
runs-on: ubuntu-latest
environment: release
defaults:
run:
working-directory: packages/hello-cliThe permissions are minimal: id-token: write for OIDC and contents: read for checkout, nothing more. Specifying environment: release means the job runs only after clearing the protected Environment’s approval gate configured earlier.
The steps add a few quiet hardening measures too.
steps:
- name: Checkout repository
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10
- name: Setup Node.js
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e
with:
node-version: "24"
registry-url: "https://registry.npmjs.org"
package-manager-cache: false
- name: Install required npm CLI
run: npm install -g npm@11.15.0Every third-party action is pinned by commit SHA rather than a tag, because tags are mutable and a rewritten upstream would run code you never intended. Disabling dependency caching with package-manager-cache: false keeps a cache-poisoning path out of the release job. Trusted Publishing does not support self-hosted runners, so this runs on the GitHub-hosted ubuntu-latest. To satisfy the requirements (Node.js 22.14+ and npm 11.15.0+ for staged publishing), the job reinstalls npm explicitly.
Just before publishing, it inserts a republish guard and a CLI output check.
- name: Install dependencies
run: npm ci
- name: Guard against republishing
run: |
PACKAGE_VERSION=$(node -p 'require("./package.json").version')
if npm view "@codenote-net/hello-cli@${PACKAGE_VERSION}" version >/dev/null 2>&1; then
echo "@codenote-net/hello-cli@${PACKAGE_VERSION} is already published"
exit 1
fi
- name: Verify CLI output
run: |
OUTPUT=$(node bin/codenote-hello.js)
test "$OUTPUT" = "Ohayou gozaimasu, Konnichiwa, Konbanwa!"
- name: Stage package
run: npm stage publishThe last step being npm stage publish rather than npm publish is the single most important thing about this workflow. CI only stages the package; it never makes it live.
Why go stage-only
Staged publishing publishes to a “staged” state instead of going live immediately. While staged, the package is not installable; a maintainer inspects it and then promotes it to live with an MFA-gated approval.
This recipe goes stage-only to split release authority between CI and a human. What CI (repository write access plus OIDC) can reach is “staging only,” and “live promotion” stays in human hands as a separate, MFA-gated npm action. With that split, even a fully compromised CI gives an attacker nothing past a staged package; they cannot produce a live one.
You inspect and approve staged packages from an authenticated maintainer machine.
npm stage list @codenote-net/hello-cli
npm stage view <id>
npm stage download <id>
# Approve with hardware MFA if it looks right
npm stage approve <id>
# Reject if anything is wrong
npm stage reject <id>You can also inspect and approve from the Staged Packages tab on npmjs.com. Note that staged approval is a per-package operation and does not batch cleanly in larger monorepos; demanding batch approval would reintroduce tokens, so keep that trade-off in mind. For this single-package recipe, the granularity fits well.
The PR-merge hardening
Now bind the pieces above into a PR-merge-based release model. As stated earlier, the goal is to make sure a workflow change cannot trigger a publish without going through review.
The publish workflow fires on pull_request.closed and the if condition narrows it to “merged and labeled Type: Release.” Because the release Environment is restricted to refs/pull/*/merge, the deployment can only enter the protected Environment through the PR’s merge ref. The upshot is that one release requires all of the following:
- a version-bump PR is created
- the PR receives source review
- the PR carries the
Type: Releaselabel - the PR is merged to
main - the deployment targets the
releaseEnvironment through the PR merge ref - a maintainer approves the Environment deployment
- npm Trusted Publishing accepts the OIDC exchange
- npm receives only
npm stage publish - a maintainer separately approves the staged package with MFA
This refs/pull/*/merge behavior is not left on paper; it was validated in practice. Merging a same-repo Type: Release PR drove the workflow to the release Environment approval gate, and after approval npm stage publish succeeded. The staged package was then inspected, approved, promoted to live, and verified with npm view and npx (the validation result is recorded in issue #5).
If GitHub ever changes this evaluation behavior or the Environment branch rule rejects the deployment, the fallback is to switch to a push-on-main trigger with a preflight job that validates the merged PR metadata before entering the protected Environment.
Automating release PR creation
If you leave the version-bump PR and the Type: Release label as manual steps, you keep a place where a human can slip up on every release. So issue #7 added create-release-pr.yml, the automation half of azu’s model.
The workflow takes a release_type input (patch, minor, major) via workflow_dispatch, bumps the version, pushes a same-repo branch, and opens a PR labeled Type: Release. Its permissions are only contents: write and pull-requests: write.
on:
workflow_dispatch:
inputs:
release_type:
description: "Version bump type"
required: true
default: patch
type: choice
options:
- patch
- minor
- major
permissions:
contents: write
pull-requests: writeThe critical point is that the PR is always opened from a same-repository branch. Fork-originated pull_request runs do not receive id-token: write, so opening from a fork would break the downstream OIDC Trusted Publishing. Get this wrong and your automation quietly breaks the entire publish flow.
For per-run safety, the workflow layers several preflight checks: confirm the Type: Release label exists, confirm there is no open Type: Release PR, and confirm no same-named release branch exists, before it ever bumps, pushes, or opens the PR.
- name: Bump package version
id: bump
run: |
npm version "${{ inputs.release_type }}" --no-git-tag-version
VERSION=$(node -p 'require("./package.json").version')
echo "version=${VERSION}" >> "$GITHUB_OUTPUT"
working-directory: packages/hello-cliThe generated PR is not published automatically. It is still something a maintainer reviews and merges, and that merge is what triggers publish-hello-cli.yml. In other words, automation goes only as far as “creating the starting point of a release”; “committing to the publish” still rests on human review, merge, and MFA approval. Automation and defense in depth coexist, which is where this design lands.
This auto-creation was verified in practice too. After PR #10 was merged, running release_type=patch manually generated PR #11 (labeled Type: Release) from the same-repo branch release/hello-cli-0.1.3, with the diff limited to the version in package.json and package-lock.json (the validation result is recorded in issue #7).
Provenance and its limits
Once a publish is live, confirm the provenance on the npmjs.com package page. The page shows:
Built and signed on GitHub ActionsSource Commit(linked to a commit ongithub.com/codenote-net/cli-distribution-recipes)Build File(linked to.github/workflows/publish-hello-cli.yml)Public Ledger(a transparency-log entry)
But what provenance proves is origin, not build-time integrity. Do not conflate the two. If the build environment were contaminated, the package coming out of it would still receive a valid provenance signature. Provenance guarantees “where it came from,” not “whether the contents are clean.” To reach build-time integrity, you need isolated builds (SLSA Build L3 or higher) on top of this.
Provenance is also generated only for public repositories and public packages. This recipe’s repository is public, so it works; a private repository would not get provenance even with Trusted Publishing.
Verify
Before npm stage approve, the package is staged, not live. It is not retrievable via npx or npm install -g, so inspect the staged state first.
npm stage list @codenote-net/hello-cli
npm stage view <id>
npm stage download <id>After approving and promoting it to live, check the metadata and install paths.
npm view @codenote-net/hello-cli version
npm view @codenote-net/hello-cli dist
npx @codenote-net/hello-cli
npm install -g @codenote-net/hello-cli
codenote-helloThe expected output is:
Ohayou gozaimasu, Konnichiwa, Konbanwa!Finally, confirm on the npmjs.com page that the provenance links to codenote-net/cli-distribution-recipes and .github/workflows/publish-hello-cli.yml, and the end-to-end verification is complete.
Limits and known constraints
This recipe is not a silver bullet either. Know the constraints before you rely on it.
- Provenance proves origin, not build-time integrity: a contaminated build can still receive a valid signature; integrity needs isolated builds.
- Provenance is public-only: it is not attached for private repositories or private packages.
- Mind the move into a reusable workflow: if the publish step moves into a reusable workflow, npm Trusted Publishing must reference the caller workflow file.
- One Trusted Publisher configuration per package. Self-hosted runners are unsupported.
- Staged approval is per-package: it does not batch cleanly in larger monorepos, and batch approval would reintroduce tokens.
- Requirements: npm 11.5.1+ (11.15.0+ for staged publishing), Node.js 22.14+.
These are trade-offs rather than defects. When your requirements outgrow the assumptions, take it as the cue to step up to isolated builds or a different release model.
Conclusion
The era of running npm publish with a long-lived token is over. What this post assembled is the new default. The key points:
- Authenticate publishes with GitHub Actions OIDC Trusted Publishing and keep
NPM_TOKENat zero. For a public repo, provenance comes automatically. - Allow only
npm stage publishon the npm Trusted Publisher, nevernpm publish. CI stages; live promotion is a separate MFA-gated action. - Protect the
releaseEnvironment with required reviewers andrefs/pull/*/merge, anchoring publishing to a reviewed-PR merge. - Automate release-PR creation with
create-release-pr.yml, while leaving the commit to publish to human review, merge, and MFA approval. - Together these form a multi-boundary defense where repository write access alone cannot produce a live npm package.
- Provenance attests origin but not build-time integrity; if you need that, advance to isolated builds.
Sever the direct path from repository write to live release. To do just that, you stack several independent gates. It is more steps, but if you are responsible for a publish channel in the 2026 npm ecosystem, this much defense in depth is worth making the default.
That is hardening npm publishing with Trusted Publishing, reported from the field.
References
- cli-distribution-recipes repository
- npm Docs: Trusted publishing for npm packages
- npm Docs: Generating provenance statements
- npm Docs: Staged publishing for npm packages
- GitHub Changelog: npm trusted publishing with OIDC is generally available
- azu: Hardening npm publishing (2026)
- Distribute an npm CLI Privately via Cloud Storage — A Self-Contained Zip Recipe with Google Drive