Zach’s ugly mug (his face) Zach Leat­herman

No more tokens! Locking down npm Publish Workflows

00 December 04, 2025

With the recent spate of high profile npm security incidents involving compromised deployment workflows, I decided that it would be prudent to do a full inventory of my npm security footprint (especially for 11ty).

Just in the last few months:

  • November 2025: Shai Halud v2 (PostHog) (and PostHog post-mortem): Worm infected ×834 packages. Propagated via preinstall npm script.
  • September 2025
    1. Shai Halud (@ctrl/tinycolor, CrowdStrike): Worm infected ×526 packages. Propagated via postinstall npm script.
    2. DuckDB: targeted phishing email (with 2FA) pointed to fake domain npmjs.help. Compromised packages were published with token created by attacker.
    3. debug and chalk: same as above: targeted phishing email (with 2FA).
  • August 2025: S1ngularity (Nx) (and Nx post-mortem): well-meaning but insecure code (from approved authors) was merged which allowed arbitrary commands to be executed via content in Pull Requests to the repo. Compromised packages were published via a stolen NPM token.
Expand to see the insecure YAML from the S1ngularity attack
# Some content omitted for brevity
on:
  pull_request:
    types: [opened, edited, synchronize, reopened]
  # …
jobs:
  validate-pr-title:
    # …
    steps:
      # …
      - name: Create PR message file
        run: |
          mkdir -p /tmp
          cat > /tmp/pr-message.txt << 'EOF'
          ${{ github.event.pull_request.title }}

          ${{ github.event.pull_request.body }}
          EOF

      - name: Validate PR title
        run: |
          echo "Validating PR title: ${{ github.event.pull_request.title }}"
          node ./scripts/commit-lint.js /tmp/pr-message.txt

Given the attack vectors of recent incidents, any packages using GitHub Actions (or other CI) to publish should be considered to have an elevated risk (and this was very common across 11ty’s numerous packages).

I’ve been pretty cautious about npm tokens. I have each repository set up (painstakingly) to use extremely granular tokens (access to publish one package and one package only). This limits the blast radius of any compromise to a single package and has helped manage my blood pressure (I accidentally leaked a token earlier this year).

Security Checklist

I’ve completed my review and made a bunch of changes to improve my security footprint on GitHub and npm, noted below. The suggestions below avoid introducing additional third-party tooling that may decrease your footprint short-term (while actually increasing it long-term).

Caveat: my current workflow uses GitHub Releases to trigger a GitHub Action workflow to publish packages to npm (and this advice may vary a bit if you’re using different tools like GitLab or pnpm or yarn, sorry).

  1. Use Two-Factor Authentication (2FA) for both GitHub AND npm, for every person that has access to publish. This is table-stakes. No compromises. Require 2FA everywhere.
    • On GitHub, go to your organization’s Settings page and navigate to Authentication Security. Check the Require Two-factor authentication for everyone and Only allow secure two-factor methods checkboxes.
    • npm requires you to specify this on a per-package basis that I describe in the Restrict Publishing Access section below.
  2. When logging into npm and GitHub, use your password manager exclusively! Never type in a password or a 2FA code manually. Your password manager will help ensure that you don’t put in your credentials on a compromised (but realistic looking) domain.
    • Would you know that npmjs.help was a spoofed domain? Maybe on your average day, but on your worst day? When you didn’t sleep well the night before? 😴
  3. Review GitHub users that have the Write role in your repositories (Write can create releases).
  4. Find any repositories using NPM tokens and delete the tokens in the settings for both GitHub and npm. We’re moving to a post-token world.
  5. Switch to use Trusted Publishers (OIDC) in the Settings tab for each npm package. This will also setup the release to include provenance as well (which is great).
    • This scopes your credentials to one specific GitHub Action (you specify which file to point to in .github/workflows/) and allows you to remove any references to tokens in the GitHub Actions YAML configuration file.
    • The big goal here for me was to completely separate my publish workflow and credentials and disallow any access to those credentials from other workflows in the repository (usually unit tests that run on every commit to the repo). You could also use GitHub Environments to achieve this. This limits the blast radius from worm propagation (via postinstall or preinstall) to publish events only (not every commit), which is far more infrequent.
  6. Restrict npm Publishing Access in the Settings tab for each npm package. Use Require two-factor authentication and disallow tokens (recommended). Death to tokens!
  7. Check in your lock file (e.g. package-lock.json for npm). This is something I’ve personally felt a bit of resistance to, mostly because I hated managing git conflicts in these files (but dependabot has helped there). It is especially important when using a release script that uses npm packages to generate release artifacts. Prefer npm ci over npm install in your release script.
  8. GitHub Actions configuration files should pin the full SHA for uses dependencies (e.g. eleventy-plugin-vite). I learned that Dependabot can update and manage these too!

Other good ideas

Given the above changes, I would consider the following items to not to be of immediate urgency (though still recommended).

  • GitHub: Enable Immutable Releases preferably at the organization level. This will ensure no one can change tags and release contents after a release has been shipped.
  • Use a package manager cooldown.
  • Reduce dependencies! Every third party dependency has some risk associated with it, as you’re inheriting a bit of those developers’ security footprint too. It’s worth noting that the work being done by the folks at e18e to reduce dependency counts is making great headway to improve the ecosystem at large. You can do this in your own projects! I’m proud of the work we’ve done on @11ty/eleventy over the years (source: v3.1.0 release notes):
    Version Production Dep Count Production Size
    v3.1.0 ×142 21.4 MB
    v3.0.0 ×187 27.4 MB
    v2.0.1 ×215 36.4 MB
    v1.0.2 ×356 73.3 MB
  • Some folks recommend disabling scripts when installing (via npm config set ignore-scripts true or via stock use of pnpm). This might be marginally useful in some cases but in my opinion is just a short term solution in response to common attack patterns that we’ve already seen. Importing (or requiring) a compromised or malicious package can execute arbitrary commands without using a preinstall or postinstall script just fine. If you really need to lock down your environment, you might consider running a Virtual Machine, Dev Container, and/or using Node.js’ Permissions model or stock Deno.

Stay safe out there, y’all!

Additional Reading


Older >
How to Hallucinate using Web Components

Zach Leatherman IndieWeb Avatar for https://zachleat.com/is a builder for the web at Font Awesome and the creator/maintainer of IndieWeb Avatar for https://www.11ty.devEleventy (11ty), an award-winning open source site generator. At one point he became entirely too fixated on web fonts. He has given 86 talks in nine different countries at events like Beyond Tellerrand, Smashing Conference, Jamstack Conf, CSSConf, and The White House. Formerly part of CloudCannon, Netlify, Filament Group, NEJS CONF, and NebraskaJS. Learn more about Zach »

Shamelessly plug your related post

These are webmentions via the IndieWeb and webmention.io.

Sharing on social media?

This is what will show up when you share this post on Social Media:

How did you do this? I automated my Open Graph images. (Peer behind the curtain at the test page)