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

By Jag Reehal, 2026-06-08. Canonical: https://arrangeactassert.com/posts/the-trusted-publishing-setting-that-would-have-blocked-the-npm-snapshot-branch-attacks/

<!-- Excerpt Start -->

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.

<!-- Excerpt End -->

## 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:

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:

```yaml
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` (and `v*` 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, and it only helps while the attacker cannot disable the rule)

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 and the attacker cannot first remove or weaken the protection.

## The hardened release workflow

```yaml
# .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: release` on the publish job pairs with the npm field
- `id-token: write` goes **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, if the rule still exists and 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, and in some cases had enough GitHub privilege to remove branch protection first. Environment binding does not stop that. Pull request review and signed commits still raise the bar, but they help only while those protections still stand and the attacker cannot edit them. See [the GitHub and npm settings checklist](/posts/the-github-and-npm-settings-open-source-maintainers-should-turn-on-before-they-need-them/) section 2 for that layer.

The practical way to make that harder is to split authority. Let the account that pushes code be a non-admin maintainer account. Keep rulesets, environments, and recovery on a separate admin identity that is not used for normal development. If you use required reviewers, make that a third identity or a teammate account, not the same session that authors the change. That does not make account compromise harmless. It does stop one stolen day-to-day account from automatically being allowed to dismantle the repo settings first.

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.

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](/posts/the-github-and-npm-settings-open-source-maintainers-should-turn-on-before-they-need-them/) covers the rest.

While you harden publishing: the [package manager settings that would have blocked the Axios attack](/posts/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.