Arrange Act Assert

Jag Reehals thinking on things, mostly product development

The trusted publishing setting that would have blocked the npm snapshot-branch attacks

08 Jun 2026

In 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 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:

  1. Pushed a throwaway branch with a random name (snapshot-a1b2c3d4)
  2. Added its own .github/workflows/release.yml on that branch, the same filename as the project's real release workflow, disguised with an innocent display name ("Dependabot Updates")
  3. Gave the job id-token: write and 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:

The chain now holds:

  1. npm demands an environment: release claim
  2. GitHub only grants that environment to jobs running from main
  3. A workflow running from snapshot-a1b2c3d4 gets 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:

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:

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.

security npm supply-chain github-actions