2026 has been a wild ride for supply-chain security. Every week, a new attack. Whether AI is to blame is a good question for another time, but the vector is often the same: targeting high-profile npm packages to deliver malware.
For a TL;DR on how to defend yourself as a consumer of packages, see below. This article is about how we secured the publishing side of nuqs to avoid becoming a vector of supply-chain attacks ourselves. As I write this, nuqs has 100M downloads all-time, over 3M weekly.
Our publishing process takes a given point in Git and turns it into a live, version-bumped npm package on the registry, plus the matching GitHub release for the changelog:
29990b5fnuqs@2.9.0v2.9.0 + changelogThe TanStack attack
I had been wanting to refactor our release process for a while, but the first call to action was the TanStack attack, where attackers poisoned the GitHub Actions cache to piggyback their malware on a legitimate publishing run.
While nuqs didn’t have the immediate “pwn request” (pull_request_target + checkout) pattern in its workflows, the attack was scary enough that I immediately paused accepting PRs from external contributors. This was a temporary measure while I assessed the security of our workflows.
The security scan came back clean (I built a skill which I also used to help a few OSS maintainer friends find issues in their workflows), but I wanted to go further. The threat model for the defences I wanted to put in place was: “imagine GitHub itself is compromised”. After all, they just had their repositories stolen and leaked and possibly sold to… god knows who.
What if GitHub cannot be trusted?
So if GitHub (or rather, GitHub Actions) could not be trusted, we needed to place our defences around it, rather than rely on anything we could do inside it. The thing I wanted for the longest time was proper 2FA for publishing on npm. If a malicious actor got their hands on a publishing token, they would be stuck at the 2FA step, which (if done properly) would also act as a notification system:
hey, I didn’t press publish, what’s that 2FA request? Insta-block. 🚫
That fortunately came out a few days later in the form of npm staged publishing (docs).
npm staged publishing
Instead of publishing a package directly to the live npm registry, staged publishing gives you this “staging area”, where a package can land and wait. With this, you get:
- Provenance (trusted publishing signatures) just like before
- An email notification that a package was staged
- A way to download the staged tarball for inspection
- A 2FA-gated way to reject (drop) or approve (publish onto the registry) the
package@versioncombo.
This is great: in the scenario above, an attacker would get their malicious package stuck in the staging area, I’d be notified about it via email, and I could review it before taking action:
npm stage publishnpm stage download <uid>on npmTarball dropped
Inspect tarballI first ran some experiments to understand how it behaves. The TL;DR is:
- A package can be staged for an indefinite amount of time (there are no timeouts that evict it)
- You may download the tarball for inspection, but it requires an authenticated npm CLI (that’s annoying for registry scanners)
- You can’t stage the same
package@versioncombination twice (409 Conflict) - You can’t stage a
package@versionthat’s already live on the registry (409 Conflict) - You can stage, reject, then stage the same
package@versionagain. Handy for abort + retry. - Both approve and reject need 2FA
So when staged publishing became available, I flipped the switch in nuqs: it would only ever be published through that staging area. At that time the latest release was back in February, a few months prior, but I had plans to ship some bug fixes and new features. Those would have to wait, security is more important.
Splitting the atomic release
Our publishing workflow was based on semantic-release, which dealt with:
- Bumping the version number based on conventional commits:
doc:,test:,ci:,chore:etc. no-op (no bump contribution)fix:patch bump (x.x.+1)feat:minor bump (x.+1.0)any!:major bump (+1.0.0, note the!)
- Creating a Git tag
- Creating a GitHub release (we had a custom release notes generator that would update the release body after publishing to thank contributors)
- Publishing to npm
- Commenting on PRs & related issues that the release was available
This had been a great DX: in order to cut a release of nuqs, all I had to do was:
- fast-forward merge
masterto wherevernext(our default/development branch) was - push it, which would also deploy the docs to nuqs.dev, so that the docs cover the latest GA:
master trails nextsemantic-releaseThis setup had a few issues though:
- It coupled
masterto two things: publishing the package and deploying the docs in production. In some cases, I had fixes that weren’t ready for prime time (going through beta rounds) interleaved with docs updates (sponsors, styling, dependencies like the RSC vulnerability patching). I got by with cherry picking and force-pushingmasterto sync it back, but it was hacky. - semantic-release would have needed to handle a two-phase publishing process, with a pause while we’d review staged packages.
I initially thought we could do this quickly by pausing/blocking the release workflow while the package was staged, but since staged packages have no timeout (I’ve had one staged for an entire month now), it doesn’t play well with the billed minutes of GitHub Actions (yes, they are free for OSS, but burning compute for idle waiting doesn’t sit right with me).
draft validate finalize
So the new publishing plan was split into three phases:
A draft phase, triggered manually (via workflow_dispatch), where we would:
- walk the commit tree like semantic-release does, to compute the new version and apply it to the package
- npm stage publish the package with provenance (OIDC trusted publishing)
- open a draft GitHub release with the release notes already formatted
workflow_dispatchrelease-draft.ymlid-token: writecontents: writeThen, the control flow would be handed over to the maintainer for 2FA. This is where validation would occur. Before hitting “publish”, we need to know if a staged tarball is legit. We’ll come back to how this is done in a minute.
Note that at this stage, if we decided to abort, nothing is immutable yet: no Git tag, the GitHub release is in draft mode and can be discarded, the staged npm package can be rejected. So another part of this design was to allow steps to fail without permanent consequences (I love the “let it crash” philosophy of Elixir/Erlang). That solves another issue I had with semantic-release: that process was not idempotent.
Once we’re good to finalize the release, the next two steps are manual again:
- approving the staged npm package to land on the registry, with 2FA
- publishing the GitHub release
That second step is what gives us the Git tag that serves as a reference for computing versions. It’s also the trigger for commenting on issues & PRs, which uses the same Git tree walking code as the initial version computation + changelog generation:
release: publishedrelease-finalize.ymlissues: writepull-requests: writediscussions: writeBefore that, I used to curate PRs and issues to be included in the changelog via milestone automation (”🪵 Backlog” ”🚀 Shipping next”), which leveraged pull_request_target to work on external contributor PRs from forks. That system is gone, everything is now derived from the Git tree alone.
So the complete flow looks like this:
Compute the version, stage to npm with provenance, open a draft GitHub release.
workflow_dispatchSomehow check the package is legit, then approve the staged package.
maintainer + 2FAPublishing the draft creates the Git tag on GitHub, then comments & labels land on the shipped issues and PRs.
release: publishedBut we need to talk about validation. Because there’s something really cool that came out of that: we have reproducible builds! (we already had them before, but now they are provable).
Validation: reproducible builds
When you publish a package on npm (whether staged or directly), the API gives you back two things:
shasum, a legacy SHA-1 of the tarball bytesintegrity, a SHA-512 hash of the same tarball bytes, which is written into lockfiles.
That integrity property is gold, as it gives us a target to match when reproducing the tarball locally. If the hashes match, it means my machine produced the same code that will land on the registry (no GitHub Actions-side tampering). If they don’t match, we can look a little closer at the file diffs (but downloading the staged tarball requires an auth’d npm CLI, and I don’t usually leave credentials lying around).
So we have a way to, completely offline, reproduce the npm pack behaviour (in a Docker image that uses the same base as GitHub Actions, so the tar internals match) and detect tampering:
packages/scripts/verify-release/verify.staged.ts \
--package nuqs \
--version '2.9.0' \
--integrity 'sha512-1ckQBhVHaQYjLZ3kWhhH40A32lPJ7TNTQMa2T+SUvl5azyVt7swCQfUXt9xOXJV3YidWq8VHIHcMzVAJ0knRsw==' \
--shasum '398efd8c07a46ef1e819c1bf93df176510fd7b5b' \
--sha '29990b5ffca4b5041202ca46799cd43542d4fcb8'We can go one step further and offer the same verification for any release on npm: query the registry API to get the integrity field from the packument (package metadata), and run the check against that. We even have the Git SHA-1 burned into the provenance metadata to verify that the Git tags match (we have immutable releases enabled, but this provides an external means of verification).
You can run this yourself in the nuqs repo:
$ pnpm verify v2.9.0
==> Fetching tag v2.9.0
==> Reading published metadata for nuqs@2.9.0
integrity : sha512-1ckQBhVHaQYjLZ3kWhhH40A32lPJ7TNTQMa2T+SUvl5azyVt7swCQfUXt9xOXJV3YidWq8VHIHcMzVAJ0knRsw==
shasum : 398efd8c07a46ef1e819c1bf93df176510fd7b5b
gitHead : 29990b5ffca4b5041202ca46799cd43542d4fcb8
tag commit: 29990b5ffca4b5041202ca46799cd43542d4fcb8
==> Building canonical image (node 24.11.0, npm 11.16.0, pnpm 11.0.9)
==> Reproducing nuqs@2.9.0 from v2.9.0
==================================================================
package : nuqs@2.9.0
reproduced .tgz : nuqs-2.9.0.tgz
==================================================================
reproduced integrity : sha512-1ckQBhVHaQYjLZ3kWhhH40A32lPJ7TNTQMa2T+SUvl5azyVt7swCQfUXt9xOXJV3YidWq8VHIHcMzVAJ0knRsw==
published integrity : sha512-1ckQBhVHaQYjLZ3kWhhH40A32lPJ7TNTQMa2T+SUvl5azyVt7swCQfUXt9xOXJV3YidWq8VHIHcMzVAJ0knRsw==
reproduced shasum : 398efd8c07a46ef1e819c1bf93df176510fd7b5b
published shasum : 398efd8c07a46ef1e819c1bf93df176510fd7b5b
==> PASS — nuqs@2.9.0 reproduces from v2.9.0Hardening GitHub Actions workflows
Now that this 3-step flow is in place, let’s have a look at a few other things that were improved. Most of those were found by zizmor and actionlint, which now review every change to our GitHub workflows:
- SHA-1 pinning for all action dependencies (
- uses: org/repo@<sha1>). Once this was done I flipped the “Require SHA-1 pinning” setting for the repo.pinactwas very useful for this. - Avoiding template expansion of attacker-controlled input like
${{ github.head_ref }}, which gets expanded before the shell scripts run. Use the env to pass it as a string instead. - Permissions scoping:
- Don’t define wide permissions at the top of the workflow file, specify each job’s permissions independently.
- Make sure your token is read-only by default in the GitHub Actions settings of your repo.
- Break down a job into multiple ones. This was the case for our npm stage release draft sequence: the
id-token: writeused for OIDC trusted publishing needs to be short-lived, and creating a GitHub release doesn’t need it, so those two jobs are separate.
- Not restoring caches on sensitive steps, to avoid cache-poisoning attacks.
- Setting
persist-credentials: falseonactions/checkout, to avoid leaving theGITHUB_TOKENin the.git/configfile. - Dropped the now-unused
master&betabranches.
The TanStack team added a great follow-up post to their post-mortem, detailing how they hardened their CI. I recommend reading it too.
Going further
Our release notes generator also got a little makeover. Since it’s no longer curated and depends on the commits between two tags, it now handles:
- A dedicated “Breaking changes” section (we’ll try that in
nuqs@3.0.0), with prose to add migration guidelines - Direct commits (sometimes I push directly to
nextfor some small README fixes etc., but I might enable branch protection to always require a PR) - Linking to related/closed issues & discussions
- Thanking contributors (humans only, sorry bots 🚫🤖)
For almost three years, I had been collecting those release notes and updating a comment in a GitHub issue with them to obtain a changelog. It was tedious, and time to automate that too.
So the release notes generator also emits an HTML comment containing a machine-readable JSON DTO of the PRs, commits & contributors it rendered to Markdown above.
## Features
- #1234 - global defaults for all parsers, by @franky47 (closes #1200)
## Bug fixes
- #1240 - clear empty arrays from the URL, by @contributor
## Thanks
Huge thanks to @franky47 and @contributor for helping!
<!--
Any Markdown between the preamble tags is rendered on the changelog page:
<changelog:preamble></changelog:preamble>
<changelog:dto>
{
"$schema": "https://nuqs.dev/schemas/changelog-dto.v1.json",
"changes": [
{
"source": "squashedPR",
"prNumber": 1234,
"type": "feat",
"breaking": false,
"description": "global defaults for all parsers",
"author": "franky47",
"closingIssues": [1200]
},
{
"source": "squashedPR",
"prNumber": 1240,
"type": "fix",
"breaking": false,
"description": "clear empty arrays from the URL",
"author": "contributor",
"closingIssues": []
}
],
"contributors": ["franky47", "contributor"]
}
</changelog:dto>
-->That DTO can then be read and rendered in the docs using a single GitHub API query to list releases. We used to have an N+1 query problem where each PR line would query its own state; now it’s much leaner. Check out the resulting Changelog page.
Finally, controlling the notification posted on issues & PRs allowed me to remove a few other friction points:
- The npm link now points to npmx.dev (a much better frontend for the registry)
- We can now comment on related discussions, not just PRs & issues
- We have different wording for beta vs GA, issue vs PR vs discussion
It’s all about the little things that make it nicer to work with.
Timeline
Mini Shai-Hulud attackers compromise TanStack packages
nuqs stops accepting PRs from 3rd party contributors (discussion).
With the
/audit-github-actions
skill.
The PR gets merged, documentation lands on npm docs.
I run experiments on the staged publishing workflow.
Announced in npm 11.15.0.
nuqs@2.9.0First GA release with the new flow.
Defending yourself
If you made it all the way down here, thanks!
Here are a few things I did as a package consumer to harden my dev environment:
- Use pnpm 11, which has better built-in security defaults
- All postinstall scripts are disabled and require explicit opt-in
minimumReleaseAgeset to 24h, leaving time for registry scanners like Snyk or Socket.dev to do their work
- Use the free Socket Firewall to block installation of known malicious packages
I’m not entirely satisfied with those steps, they each have a flaw:
- What if a postinstall-allowed package gets pwned?
- What if the malicious package is a slopsquat that flew under the radar of scanners, and passed through the firewall?
minimumReleaseAgealso delays critical security vulnerability fixes (e.g. Next.js RSC)
Some of the things I’d like to do next:
- Get a YubiKey for more secure 2FA TOTP secret storage (if someone would like to sponsor it, I’d be eternally grateful 💖)
- Try Drydock by Jovi to further analyse a mismatching local build in the verification step.
- Try Automic Vault (by Max Howell, the creator of Homebrew), an OS-wide linter & watcher that detects issues that could get your system compromised.
Stay safe out there. 🫶