
Jovi De Croock
Software Engineer
Hardening npm Publishing
Supply chain attacks on npm packages aren't hypothetical anymore. Compromised maintainer credentials, malicious publish workflows, and stolen tokens have caused real harm to real users. This post isn't here to point fingers at anyone, maintaining open-source is hard and most of these problems don'tab have silver-bullet fixes, it's all about building as much protection as we can together.
What I want to share is the setup I've been pushing in projects I maintain, most recently in preactjs/prefresh#615, and what I think both maintainers and consumers can do to make this better together.
For maintainers
Automate publishing with OIDC, not stored tokens
The most common setup I still see is a long-lived npm token stored as a repository secret, used in a CI workflow that publishes on every push to main. The problem is that this token is available to any workflow in that repository. A compromised workflow, a malicious contributor getting a merge through — any of these can exfiltrate the token and publish whatever they want.
The better approach is npm's trusted publishing combined with OIDC. Instead of storing a token at all, your CI job requests a short-lived credential directly from npm using GitHub's OIDC identity. No secret to store, no secret to steal.
publish:
runs-on: ubuntu-latest
environment:
name: npm
url: https://www.npmjs.com/search?q=%40yourscope
permissions:
contents: write
id-token: write
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
registry-url: 'https://registry.npmjs.org'
- run: npm publish --provenance --access public
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
The id-token: write permission is what enables the OIDC flow. The environment: npm
is what gates it.
Gate publishing behind a GitHub Environment
That environment: npm declaration in the workflow is doing more work than it might
look like. GitHub Environments can require manual approval before a job runs, restrict
which branches can deploy to them, and have their own scoped secrets. This means even
if someone pushes a bad commit to main, the publish job won't run without an explicit
approval step from someone with access to that environment.
Set this up in your repository's Settings → Environments. Create a npm environment,
add protection rules (required reviewers, or at minimum branch restrictions), and scope
any publish-related secrets to that environment rather than the repository.
Enable provenance for your packages
Provenance links a published npm package back to the exact source commit and CI run that
produced it. Consumers can verify the chain from package back to code. Add this to your
package.json publishConfig:
{
"publishConfig": {
"access": "public",
"provenance": true
}
}
When combined with OIDC publishing, npm will attach a signed attestation to the release. Anyone who installs your package can verify that the bits they got came from your repository and not from someone's local machine.
Limit who can publish manually
Automation is the goal, but sometimes someone needs to publish manually. The important thing here is keeping that list short. The more accounts hold publish access to a package, the more credential compromise vectors there are.
On npm, this means auditing your package's collaborator list regularly and removing people who no longer need access. For the remaining publishers, 2FA on their npm account is non-negotiable — npm has supported enforcing this at the package level since granular access tokens were introduced.
The mental model I use: every additional publisher is another door. Keep the list to the people who genuinely need it.
An even safer way to go about this is to create a shared npm user, only one, where you store the password and 2FA token in a password manager. This user can only come out for emergencies. Not having these credentials on a device, not even for the vault would be the safest approach.
For consumers
Use pnpm
If you're still using npm as your package manager, it's worth switching to pnpm. Beyond the well-known performance wins, pnpm is simply more reliable day-to-day — npm has a long history of subtle bugs around lockfile resolution, lifecycle script ordering, and peer dependency handling that have a habit of causing problems at the worst moments.
More relevant to security: pnpm's default module resolution doesn't hoist packages into
a flat node_modules. Each package can only access what it explicitly declares as a
dependency. A compromised transitive dependency can't silently reach code it was never
supposed to touch.
minimumReleaseAge
pnpm has native support for delaying resolution of newly published packages. Since pnpm
v11 this defaults to 1440 minutes (one day), so you're already protected out of the box.
You can tune it in pnpm-workspace.yaml:
minimumReleaseAge: 4320 # 3 days, in minutes
minimumReleaseAgeStrict: true # fail resolution rather than fall back to an older version
minimumReleaseAgeStrict is the important one — without it, pnpm will silently fall back
to a version that does meet the age requirement rather than erroring. If you want the
protection to be meaningful, turn strict mode on.
If you need to install a security fix before the age window clears, pnpm audit --fix
will automatically add the package to minimumReleaseAgeExclude so it's not blocked.
Watch for provenance regressions
pnpm's trustPolicy setting handles this directly. Set it to no-downgrade and pnpm
will refuse to install a version whose trust level is lower than a previously installed
version of the same package — for example, if a package drops from trusted publisher
attestation down to provenance-only, or from provenance down to nothing:
trustPolicy: no-downgrade
A package that goes from provenance → no provenance between versions deserves a closer
look before you upgrade, and with no-downgrade you won't accidentally pull it in.
None of this is a complete solution. A sufficiently motivated attacker can compromise a CI system directly, social-engineer their way into an environment approval, or find other angles. But the goal isn't perfection — it's reducing the blast radius of the most common paths.
Most incidents involve stolen credentials, long-lived tokens in repository secrets, or broad publish access that nobody audited. The mitigations above close all three of those directly.
Both sides of this — the maintainer publishing the package and the consumer pulling it in — have a part to play. The more packages adopt provenance, the more consumers can use it as a signal. The more consumers configure minimumReleaseAge, the more time there is for the community to catch problems. It compounds.