September 8, 2026

npm Supply Chain: The Worm That Walked In Through Trusted Publishing

Photo of Marco Orta Marco Orta | 13 min read
Compartir
Typographic cover on a terminal background: the word npm above an .npmrc diff, "- min-release-age unset" in red and "+ min-release-age=7" in green
Table of Contents

    For a year, npm security advice was a single sentence and everybody repeated it: stop publishing with long-lived tokens and move to trusted publishing with OIDC. It was good advice. It still is. And on May 11, 2026, the Shai-Hulud worm published dozens of official @tanstack/* packages by walking in exactly that way — not by forging provenance, but by genuinely generating it from the project’s own legitimate workflow.

    That distinction is the whole article. A malicious package with an authentic Sigstore signature, issued by Fulcio, logged in Rekor, backed by the real repository context. Every indicator you were taught to check said “fine.”

    This doesn’t mean trusted publishing is useless, or that anyone should go back to tokens. It means something more uncomfortable: it’s a layer, not a perimeter, and we spent a year selling it as the second thing. Here’s what it actually protects, what it leaves out, and the four measures that would have stopped the last twelve months of attacks.

    The timeline, with dates

    Worth having in front of you, because the cadence matters more than any single incident:

    DateWhat happened
    Jul 2025Trusted publishing with OIDC goes generally available on npm
    Aug 26, 2025S1ngularity attack
    Sep 8, 2025Phishing against debug, chalk and 16 more utilities — live for two hours
    Sep 15, 2025Shai-Hulud wave 1: 700+ packages compromised
    Nov 23, 2025Shai-Hulud 2.0
    Dec 9, 2025npm permanently revokes every classic token
    Feb 2026npm CLI 11.10.0 ships min-release-age and bulk OIDC configuration
    Mar 30, 2026[email protected] compromised — 100M weekly downloads
    May 11, 2026Shai-Hulud against TanStack, entering through trusted publishing itself
    May 14, 2026node-ipc: three malicious versions with an 80 KB credential stealer
    Jun 1, 202632 @redhat-cloud-services packages, payload named “Miasma”
    Aug 2026New wave: keyv and 400+ packages

    Ten months, eight campaigns. This is no longer a series of incidents: it’s the registry’s normal operating state.

    How it came in through the good door

    The TanStack attack is worth following step by step, because each link dismantles a different assumption. Upwind’s reconstruction lays it out like this:

    1. A “dropper” dependency lands in package.json and runs during installation via npm’s lifecycle hooks. Nothing exotic: that’s documented behaviour.
    2. The dropper downloads and runs the Bun runtime. That way it doesn’t depend on the installed Node and it sidesteps monitoring that watches for Node executions.
    3. Under Bun runs a heavily obfuscated 2.3 MB payload (router_init.js) that daemonises itself and stays alive after the install finishes.
    4. Credential sweep: filesystem, cloud APIs, and — this is the good part — GitHub Actions runner memory, scraped from /proc/*/mem. Targets: AWS keys, npm tokens, Kubernetes service accounts, AI assistant configs.
    5. OIDC abuse: rather than needing a stored npm token, it exchanges the GitHub Actions OIDC token for dynamic publish access. The workflow’s id-token: write permission is the key.
    6. Propagation: with that access it downloads target packages, injects itself, bumps the version by exactly +3 and republishes.
    7. And along the way it mints a Sigstore bundle through Fulcio and Rekor — so the malicious tarball ships with provenance generated from the trusted workflow context.

    Point 7 is the one to internalise. There was no forgery. The chain of trust worked exactly as designed; what was compromised was the link before it, the CI runner. Provenance answers “was this built where it says?”, and the answer was yes. It does not answer “is what was built what the maintainer wrote?”, which was the question that mattered.

    The published indicators, in case you need to sweep: workflows talking to api.masscan[.]cloud or 83.142.209.194, and persistence artifacts named router_init.js, pgmonitor.py or pgsql-monitor.service.

    So is trusted publishing worth it?

    Yes, very much so. But the split is worth being explicit about, because the generic advice never is:

    ThreatDoes OIDC stop it?
    npm token stolen off a laptop✅ Yes — there’s no token to steal
    Token leaked in a CI log or a .env✅ Yes
    Maintainer phishing (the debug/chalk case)✅ Largely
    Token still valid months after a maintainer leaves✅ Yes
    CI runner compromised during the buildNo
    Malicious dependency running in postinstallNo
    CI cache poisoningNo
    Maintainer’s GitHub account compromised⛔ No

    The correct reading: OIDC eliminates the long-lived secret, which was the dominant vector through 2025. It does not defend the process that issues the publish. And as the first vector closes, attacks move to the second. That is precisely what the timeline above shows.

    One number to calibrate how many people are still in the first box: according to Aikido’s analysis, of the 51,370 most-downloaded packages only 11,001 use trusted publishing — 21.4%, covering barely 25% of download volume. Three quarters of what you install every day is still published with tokens. And adoption moves incident by incident: from a baseline of ~35 new packages a week it spiked to 372 during Shai-Hulud 2.0, then the urgency deflated again.

    So the message isn’t “trusted publishing failed.” It’s “migrate anyway, and also do these four things.”


    The four measures that would actually have stopped this

    1. Dependency cooldown: don’t install what shipped this morning

    This is by far the best impact-to-effort ratio available, and still the least used.

    The idea is offensively simple: tell your package manager to ignore versions published less than N days ago. Almost all of these campaigns are detected and pulled within hours — the malicious debug and chalk versions were live for two hours — so a modest delay takes you entirely out of the exposure window. Of ten attacks analysed, eight had windows shorter than a week.

    All four managers support it natively now, with the trap that the units don’t match:

    ManagerKeyUnitSince
    npmmin-release-agedaysCLI 11.10.0 (Feb 2026)
    pnpmminimumReleaseAgeminutes10.16 (Sep 2025)
    YarnnpmMinimalAgeGateminutesBerry 4.10.0
    BunminimumReleaseAgeminutes

    In npm, one line in .npmrc:

    min-release-age=7
    

    In pnpm, inside pnpm-workspace.yaml, with an exclusion list for whatever you publish yourself:

    minimumReleaseAge: 10080          # 7 days in minutes
    minimumReleaseAgeExclude:
      - '@my-company/*'
    

    Two practical warnings. First: the cooldown is enforced at install time, not at update-suggestion time, so your dependency bot can open the PR and the npm ci fail afterwards; set the same threshold in both places. Second: Yarn has a known bug parsing duration strings like "7d" — give it a number of minutes and move on.

    2. Separate the runner that installs from the runner that publishes

    This is the one that would have cut the TanStack attack dead.

    The structural problem is that in most workflows the same job runs npm ci and npm publish. That means third-party code executing during install lives in the same process that holds — or can request — the OIDC token.

    The right shape is to split it, with id-token: write existing only in the publishing job, which installs nothing from third parties:

    jobs:
      build:
        runs-on: ubuntu-latest
        permissions:
          contents: read          # no id-token here
        steps:
          - uses: actions/checkout@v5
          - run: npm ci --ignore-scripts
          - run: npm run build
          - run: npm pack
          - uses: actions/upload-artifact@v4
            with: { name: tarball, path: '*.tgz' }
    
      publish:
        needs: build
        runs-on: ubuntu-latest
        permissions:
          contents: read
          id-token: write         # the permission lives only here
        steps:
          - uses: actions/download-artifact@v4
            with: { name: tarball }
          - run: npm publish *.tgz --provenance
    

    Note the --ignore-scripts on the install step. Lifecycle hooks are steps 1 and 2 of the attack; if your build doesn’t need them — and many don’t — turning them off in CI is free. If some dependency genuinely does need them, the exception list is shorter than you think and worth writing by hand.

    3. Treat the CI cache as untrusted input

    The initial vector of the May wave was cache poisoning. It’s an unintuitive vector because the cache looks like your own infrastructure, and it isn’t: in GitHub Actions, a lower-privileged branch can write into a cache that the release branch later reads.

    The minimum:

    • Never use pull_request_target without an explicit trust boundary. That trigger runs with the base repository’s secrets and the PR’s code in front of it; it’s the foot in the door.
    • Segregate cache keys by branch or by purpose. The release workflow should not share a key with the PR workflow.
    • In the publishing job, don’t restore a cache at all. That job should be as boring as possible: download an artifact, upload it. Nothing else.

    4. Stop reading the provenance badge as a verdict

    Provenance is still useful: it tells you where a package was built. What you can’t do after May is read it as “this is safe.”

    In practice that means dependency review doesn’t get delegated to a badge. What does work:

    • Pin with package-lock.json and always use npm ci in CI, never npm install. That’s the difference between reproducing a tree and resolving a new one every time.
    • Watch for odd version jumps. The worm bumped versions by exactly +3. A patch that skips three numbers with no changelog is a cheap and surprisingly good signal.
    • Rotate credentials when a wave hits you, and rotate everything: npm, GitHub, Actions secrets, AWS, Vault, Kubernetes, SSH. These payloads sweep the entire runner, not just the publish token.

    And what npm changed on its own

    Part of the work has already been done for you, and it’s worth knowing because it affects scripts you may still have lying around:

    • Classic tokens have been revoked since December 9, 2025. Not deprecated: revoked. If an old pipeline of yours stopped publishing on that date, this is why.
    • npm login no longer hands you a long-lived token, but a two-hour session that expires on its own and enforces 2FA for publishing.
    • Granular write tokens expire after 90 days at most. The eternal token no longer exists, even if you ask for it.
    • And since npm CLI 11.10.0 you can configure OIDC in bulk across many packages at once, which was the real friction in migrating an org with fifty packages.

    If you publish packages, migrating to trusted publishing is literally four lines of YAML — the id-token: write and the --provenance from the example above — plus setting the trusted publisher on the package page. Do it. But do it knowing you’ve just closed the 2025 vector, not the 2026 one.

    What I’d do this month, in order

    If you have half an hour, in decreasing order of return:

    1. Set min-release-age=7 (or your manager’s equivalent) in every repository. Five minutes, cuts off most known campaigns.
    2. Swap npm install for npm ci --ignore-scripts in every CI workflow that doesn’t need hooks.
    3. If you publish packages, split the workflow into build and publish, with id-token: write only in the second.
    4. Migrate to trusted publishing anything still publishing with a granular token.
    5. Audit your pull_request_target triggers and any cache keys shared across branches.

    None of the five is expensive. And here’s the uncomfortable part: the first one, the most effective of all, requires trusting nobody and understanding no attack. Just waiting a week.

    Further reading:

    Frequently asked questions

    Is trusted publishing with OIDC still worth it after the May 2026 attack?

    Yes, and you should migrate anyway. OIDC completely eliminates the long-lived token, which was the dominant vector through 2025: there is nothing to steal from a laptop, from a CI log, or from a maintainer via phishing. What it does not cover is the process that issues the publish: if the CI runner is compromised during the build, the attacker exchanges the OIDC token exactly as the legitimate workflow would. It is a layer, not a perimeter.

    How did the Shai-Hulud worm generate valid Sigstore provenance?

    Because it did not forge it: it genuinely generated it. The malicious payload ran inside the project's own GitHub Actions runner, requested an OIDC token using the workflow's id-token: write permission, exchanged it for npm publish access, and minted a Sigstore bundle through Fulcio and Rekor from that trusted context. Provenance answers "was this built where it says?", and the answer was yes. It does not answer "is what was built what the maintainer wrote?".

    What is a dependency cooldown and how do I configure it?

    It tells your package manager to ignore versions published less than N days ago, so you never install a malicious version inside its lifetime, which is usually measured in hours. In npm it is min-release-age in .npmrc, measured in days (since CLI 11.10.0, February 2026); in pnpm it is minimumReleaseAge, measured in minutes (since 10.16); in Yarn it is npmMinimalAgeGate in minutes, and in Bun minimumReleaseAge. A seven-day threshold would have blocked eight of the ten most recent attacks analysed.

    Why should the install job be separate from the publish job in CI?

    Because if the same job runs npm ci and npm publish, the third-party code that executes during installation lives in the same process that has access to the OIDC token. That is how the TanStack attack got in: a malicious dependency ran in a lifecycle hook, scraped the runner memory and exchanged the token. The fix is for id-token: write to exist only in a publish job that installs nothing from third parties and just uploads an already-built artifact.

    What share of npm packages already use trusted publishing?

    Not many: of the 51,370 most-downloaded packages, 11,001 use it — 21.4%, covering around 25% of total download volume. In other words, three quarters of what gets installed daily is still published with tokens. Adoption spikes with each incident (372 new packages during the week of Shai-Hulud 2.0, against a baseline of about 35 per week) and falls back once the urgency passes.

    Do npm classic tokens still work?

    No. npm permanently revoked every classic token on December 9, 2025. Since then npm login issues a two-hour session instead of a long-lived token, and granular write tokens expire after 90 days at most. If an old pipeline stopped publishing around those dates, that is the cause.

    Compartir

    Search

    Tags

    PHP AI Tutorial JavaScript Migration Laravel Web Development Best Practices Security Upgrade Laravel 13 OpenAI Backend SEO Claude