No more tokens! Locking down npm Publish Workflows
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 viapreinstallnpm script.September 2025- Shai Halud (
@ctrl/tinycolor, CrowdStrike): Worm infected ×526 packages. Propagated viapostinstallnpm script. - DuckDB: targeted phishing email (with 2FA) pointed to fake domain
npmjs.help. Compromised packages were published with token created by attacker. debugandchalk: same as above: targeted phishing email (with 2FA).
- Shai Halud (
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).
- 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.
- 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.helpwas a spoofed domain? Maybe on your average day, but on your worst day? When you didn’t sleep well the night before? 😴
- Would you know that
- Review GitHub users that have the Write role in your repositories (Write can create releases).
- 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.
- Success criteria is having 0 Access Tokens listed in your npm Settings (granular or otherwise).
- 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
postinstallorpreinstall) to publish events only (not every commit), which is far more infrequent.
- This scopes your credentials to one specific GitHub Action (you specify which file to point to in
- Restrict npm Publishing Access in the Settings tab for each npm package. Use Require two-factor authentication and disallow tokens (recommended). Death to tokens!
- Check in your lock file (e.g.
package-lock.jsonfor 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. Prefernpm ciovernpm installin your release script. - GitHub Actions configuration files should pin the full SHA for
usesdependencies (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.
- Added the
cooldownoption to my Dependabot configuration (direct link todependabot.yml). This updates production dependencies weekly, now with a 7 day cooldown. - I usually use
npm-check-updatesfor local package.json file maintenance. It has acooldownoption too! npm installdoes have a--beforeoption to pass a Date that can be used similarly (though isn’t relative).- More on socket.dev: pnpm 10.16 Adds New Setting for Delayed Dependency Updates
- Added the
- 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/eleventyover 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 trueor 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 apreinstallorpostinstallscript 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!
