October 18, 2026
1,500 npm Packages Were Open to Takeover. No Password Needed
npm trusted a repo’s name, not its ID. How the attack worked, what GitHub fixed, and a 28 line script to check your dependencies

By Martin
4 min read
Trusted publishing was supposed to be the safe way to release npm packages. No long-lived token to steal, just a short-lived one from GitHub Actions.
On 13 October 2026, researchers at Bloom Security showed it could be abused. They found more than 1,500 npm packages, with over 10 million downloads between them, that someone else could have published a new version of. No stolen password, no stolen token. They called it SlugFest.
I will be sharing how it worked, what GitHub changed on 2 October, and a small script I wrote that checks whether your dependencies' latest release came from the same repo as the one before. Everything was run on Node.js 24.18 and npm 11.16.
How trusted publishing works
When you set up trusted publishing, you tell npm three things: the GitHub repo (acme/lib), the workflow file (release.yml) and, optionally, an environment.
When that workflow runs, GitHub gives it a signed OIDC token that says which repo and workflow it came from. npm checks the token against your settings and hands back a publish credential that lasts a few minutes.
The flaw: a name, not an ID
Repo names can be reused. If you rename acme/lib, transfer it, or delete it, the name acme/lib becomes free.
Until a trusted publishing setup had published once, npm only compared names. So an attacker could:
- create a new repo at the freed name
acme/lib - add a workflow called
release.ymlwithid-token: write - run it and swap the token for a publish credential
The new version is even signed with valid provenance, because it really was built by "acme/lib". Just not the one you trusted.
Think of a parcel locker that opens for anyone who says the right name, instead of checking the ID card.
Note: the researchers have not seen it used in the wild. They proved it by publishing a new version of an existing package in a controlled test, and they are not naming the affected packages.
Four months to a fix
Bloom reported it through GitHub's bug bounty on 6 June and it was confirmed on 9 June. The researchers say their last reply came on 8 July, and their follow-ups after that went unanswered.
What GitHub changed
GitHub's changelog of 2 October 2026 says:
- a trusted publishing setup that has not published yet expires after 48 hours
- the first successful publish binds it to the repo's permanent identity
- changing the repo starts a new 48 hour window
- tokens from
issue_commentworkflow runs are now rejected
That closes most of the hole. It does not change the fact that you type a name when you set it up.
Checking your dependencies
npm keeps the provenance of each release. Inside it is the GitHub repo and its numeric ID:
"internalParameters": {
"github": { "repository_id": "245704608", "repository_owner_id": "..." }
}"internalParameters": {
"github": { "repository_id": "245704608", "repository_owner_id": "..." }
}So the check is simple. For each dependency, read the repo ID from the last two releases. If it changed, the newest release was built by a different repo, even if the name is the same:
// check-provenance.mjs (main loop; the full file is 28 lines)
for (const name of deps) {
const meta = await (await fetch(`https://registry.npmjs.org/${name.replace("/", "%2f")}`)).json();
const [prev, latest] = Object.keys(meta.time).filter((v) => meta.versions[v]).slice(-2);
const [a, b] = [await source(name, prev), await source(name, latest)];
if (!b) { console.log(`${a ? "WARN" : "info"} ${name}@${latest}: no provenance${a ? " (the previous version had it)" : ""}`); continue; }
const now = await (await gh(`repos/${b.repo}`)).json();
const flags = [a && a.id !== b.id && "repo ID changed between versions",
String(now.id) !== b.id && "repo name now belongs to another repo"].filter(Boolean);
console.log(`${flags.length ? "ALERT" : "ok "} ${name}@${latest}: ${b.repo} #${b.id}${flags.length ? " -> " + flags.join(", ") : ""}`);
}// check-provenance.mjs (main loop; the full file is 28 lines)
for (const name of deps) {
const meta = await (await fetch(`https://registry.npmjs.org/${name.replace("/", "%2f")}`)).json();
const [prev, latest] = Object.keys(meta.time).filter((v) => meta.versions[v]).slice(-2);
const [a, b] = [await source(name, prev), await source(name, latest)];
if (!b) { console.log(`${a ? "WARN" : "info"} ${name}@${latest}: no provenance${a ? " (the previous version had it)" : ""}`); continue; }
const now = await (await gh(`repos/${b.repo}`)).json();
const flags = [a && a.id !== b.id && "repo ID changed between versions",
String(now.id) !== b.id && "repo name now belongs to another repo"].filter(Boolean);
console.log(`${flags.length ? "ALERT" : "ok "} ${name}@${latest}: ${b.repo} #${b.id}${flags.length ? " -> " + flags.join(", ") : ""}`);
}source() downloads registry.npmjs.org/-/npm/v1/attestations/<name>@<version> and reads the repo and repository_id out of the SLSA provenance. The loop then asks GitHub what ID the repo name points to today.
What it found
ok @sigstore/sign@5.0.0: sigstore/sigstore-js #495574555
ok vite@8.3.4: vitejs/vite #257485422
ok zod@4.6.7: colinhacks/zod #245704608
ok express@5.3.0: expressjs/express #237159
info lodash@4.18.1: no provenance
ok semver@7.8.5: npm/node-semver #1357199
ok pino@10.4.0: pinojs/pino #51840802ok @sigstore/sign@5.0.0: sigstore/sigstore-js #495574555
ok vite@8.3.4: vitejs/vite #257485422
ok zod@4.6.7: colinhacks/zod #245704608
ok express@5.3.0: expressjs/express #237159
info lodash@4.18.1: no provenance
ok semver@7.8.5: npm/node-semver #1357199
ok pino@10.4.0: pinojs/pino #51840802Everything was fine, which is what you would hope. To test the alarm, I faked zod's latest release so it came from repo ID 999999999:
ALERT zod@4.6.7: colinhacks/zod #999999999 -> repo ID changed between versions, repo name now belongs to another repoALERT zod@4.6.7: colinhacks/zod #999999999 -> repo ID changed between versions, repo name now belongs to another repoAlso watch the WARN line. A package whose previous release had provenance and whose new one does not is worth a look too.
Tip:
npm audit signatureschecks that provenance is valid. It is valid in this attack too. Checking where it came from is the part this script adds.
If you publish packages
Run npm trust list <package> and check that every repo it lists still exists under an owner you control. Before you rename, transfer or delete a repo, remove its setup with npm trust revoke --id=<id>, not after. And publish soon after setting it up, so it gets bound to the repo ID.
Conclusion
SlugFest worked because npm trusted a name that anyone could take once it was free. GitHub's fix shrinks the window to 48 hours, but the setup still starts from a name. Check your own packages with npm trust list, and run a repo ID check like the one above on your dependencies in CI. Next, I would make the script fail the build on any ALERT.
Hope this is helpful for everyone. Please leave a comment below for any thoughts and discussion. Happy coding ;)