The trusted publishing setting that would have blocked the npm snapshot-branch attacks
08 Jun 2026In June 2026, a self-propagating npm worm compromised maintainer accounts and republished their packages with malware inside. Maintainers woke to hundreds of malicious versions across dozens of packages, often published in a few minutes overnight.
Many of those versions went out through npm's trusted publishing, the tokenless way to publish from CI, and they carried valid provenance. The green badge, the Sigstore attestation, the "built from this repo by this workflow" proof: all real.
The worm never needed an npm password for those publishes. It used the release pipeline itself.
Trusted publishing remains the right approach. Many configurations leave one field blank, and that blank field is the gap. Closing it takes about five minutes per package. This post shows the attack and the settings that would have stopped the snapshot-branch half of it.
How trusted publishing works (and what it actually checks)
Trusted publishing replaces long-lived npm tokens with a handshake between npm and your CI provider. You tell npm: "versions of this package may be published by this GitHub repository, running this workflow file." When the workflow runs, GitHub mints a short-lived OIDC token (a signed statement describing the run), npm verifies the claims, and the publish goes through. No token sits in CI secrets waiting to be stolen.
The default binding checks:
- the repository ✅
- the workflow filename ✅ (e.g.
.github/workflows/release.yml) - the branch ❌
The binding says which file may publish, but not which ref it may run from.
The attack: bring your own branch
The worm operated with stolen GitHub credentials that had push access. It avoided main, where branch protection might have noticed. Instead, for each repository it:
- Pushed a throwaway branch with a random name (
snapshot-a1b2c3d4) - Added its own
.github/workflows/release.ymlon that branch, the same filename as the project's real release workflow, disguised with an innocent display name ("Dependabot Updates") - Gave the job
id-token: writeand published
GitHub minted the OIDC token: correct repository ✅, correct workflow filename ✅. npm checked the claims: all match ✅. Publish accepted, provenance attached, badge green.
The filename check fails when the attacker controls the file. Workflow files live in the repository, so anyone who can push a branch can create a workflow with any filename they like. The OIDC token includes which ref the workflow ran on. Default npm trusted publisher config does not require it. The worm used side branches because that path was easier, not because branch data was missing from the token.
Every one of those malicious versions displayed valid provenance on npmjs.com. Provenance tells you a package was built where it says it was built. It says nothing about whether anyone authorised that build. If you treat the badge as a trust signal, this attack breaks that assumption.
The worm published with stolen credentials. GitHub and npm treated the runs as legitimate maintainer activity. The OIDC tokens and provenance were real. Authorisation was not. Platforms cannot distinguish your session from a stolen one without release rules you define.
The fix: make the branch part of the contract
npm's trusted publisher config has an optional field many people leave blank: Environment name. It adds the missing branch dimension, provided you configure both halves.
Half one: the npm side
In your package's trusted publisher settings, set Environment name to release. npm now requires the OIDC token to carry an environment: release claim. A workflow only gets that claim if its job declares:
jobs:
publish:
environment: release
Half two: the GitHub side (this is the half that does the work)
The attacker controls the workflow file. If the release environment has no rules attached, they add environment: release to their rogue workflow, GitHub grants it, and the publish goes through anyway. Configure the npm field and the GitHub environment together, or you gain nothing.
The protection lives in the environment's rules. In your repo: Settings → Environments → New environment → release, then:
- Deployment branches and tags → Selected branches and tags → add
main(andv*tags if you publish from tags) - Required reviewers → add someone other than the account that pushes code, if you can (a co-maintainer or org teammate; solo maintainers need a second approver account or this layer does little)
The chain now holds:
- npm demands an
environment: releaseclaim - GitHub only grants that environment to jobs running from
main - A workflow running from
snapshot-a1b2c3d4gets the environment refused, so no claim, so the publish is rejected
A throwaway branch does not satisfy a main-only environment.
With Required reviewers on top, a malicious commit on main should pause the publish job until another human approves it. That only works when the approver is a different account from the one the attacker stole.
The hardened release workflow
# .github/workflows/release.yml
name: Release
on:
push:
branches: [main]
permissions:
contents: read # default-deny everywhere else
jobs:
publish:
runs-on: ubuntu-latest
environment: release # grants the claim npm now demands
# refuse to run in forks/unexpected contexts
if: github.repository_owner == 'your-username'
permissions:
contents: read
id-token: write # ONLY on this job, never workflow-wide
steps:
- uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5, pin to SHA
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4, pin to SHA
with:
node-version: 24
registry-url: https://registry.npmjs.org
- run: npm ci --ignore-scripts
- run: npm run build && npm test
- run: npm publish # OIDC, no token, no NODE_AUTH_TOKEN
The details that matter:
environment: releaseon the publish job pairs with the npm fieldid-token: writegoes only on the publish job, never at workflow level- Pin third-party actions to commit SHAs, not tags. Tags can be moved to malicious commits.
- The repository-owner guard is a cheap extra tripwire
- In the npm trusted publisher settings, under allowed actions, tick only what you use (publish) and drop the rest
Why this combination works
| What the attacker did | What blocks it |
|---|---|
| Pushed a rogue workflow on a throwaway branch | Environment restricted to main → no OIDC claim |
| Reused the trusted workflow's filename | Filename no longer sufficient; the branch is checked |
Could declare environment: release themselves |
Declaring ≠ receiving; GitHub enforces the rules |
Publish from main without a second approver |
Required reviewers when someone else must approve |
| Relied on valid provenance to look legitimate | Provenance valid; the publish does not complete |
What this closes
Environment binding blocks the snapshot-branch OIDC bypass: push snapshot-*, add a rogue release.yml, publish with valid provenance. Restrict the release environment to main and the publish fails before it reaches npm.
The same June 2026 campaign used other paths. Attackers with stolen credentials pushed commits directly to main, sometimes with forged commit metadata. Environment binding does not stop that. Required pull request review and signed commits on protected branches make that path much harder. See the GitHub and npm settings checklist section 2 for that layer.
If someone fully controls your GitHub account and can change environment rules or branch protection, no repo setting saves you on its own. Account hygiene (passkeys, credential separation, recovery notes outside GitHub) matters too. Entry in these campaigns is often credential harvest on a dev machine, not a misconfigured workflow. Managed laptops do not make secure maintainers covers that side.
One stolen credential should not become silent publishes and silent commits. Different gates for different paths.
Bottom line
Trusted publishing without an environment binding trusts a workflow filename. Workflow filenames are part of the repository, and an attacker who can push a branch can create a workflow file with the same name.
The missing control for OIDC publishes is the branch rule:
- Set the Environment name in npm's trusted publisher config
- Create the matching GitHub environment
- Restrict it to
main, or to the tags you actually release from - Add a required reviewer if you have someone else who can approve
That tightens provenance from "this came from a workflow with the right name" to "this came from a release path you defined." It would have blocked the snapshot-branch OIDC publishes from the June 2026 campaign, and it blocks the same branch trick next time someone tries it.
None of this makes a maintainer invincible. Pair it with branch protection, release-file controls, tripwires, and a recovery plan: the GitHub and npm settings checklist covers the rest.
While you harden publishing: the package manager settings that would have blocked the Axios attack protect you as a consumer of packages. This post protects you as a publisher. You need both.