Contents
A Bad Month for npm
Two incidents in September 2025 changed how I think about JavaScript dependencies.
On 8 September, a maintainer of some of the most depended-on packages in the ecosystem was phished. The email impersonated npm support, warned that the account would be locked unless two-factor authentication details were updated, and linked to a look-alike domain, npmjs.help, registered a few days earlier. With the captured credentials the attacker published malicious versions of 18 packages, including chalk, debug, ansi-styles and strip-ansi, which between them are downloaded more than two billion times a week. The payload targeted browsers, hooking wallet and network APIs to swap cryptocurrency addresses. The versions were spotted and removed within hours.
A week later came something worse. Around 15 September, researchers identified a self-replicating worm, named "Shai-Hulud" after the sandworms in Dune. Infected versions, including releases of @ctrl/tinycolor, carried a large obfuscated bundle.js run by a postinstall script. On a developer laptop or CI runner it used TruffleHog to hunt for secrets (npm tokens, GitHub personal access tokens, AWS, Google Cloud and Azure keys), exfiltrated them to a public GitHub repository named "Shai-Hulud" in the victim's account and via a GitHub Actions workflow it added, and then used any npm token it found to publish infected versions of the other packages that maintainer controlled. More than 500 packages were affected, and CISA issued an alert on 23 September.
GitHub responded with a plan for npm: deprecating legacy classic tokens, moving from TOTP to FIDO-based two-factor authentication, short-lived granular tokens for publishing (seven days by default, 90 at most), and pushing publishers towards trusted publishing. These changes are rolling out now.
The two attacks hit different layers. The first reached anyone who installed a fresh version in the few hours before it was removed. The second turned install-time scripts into a credential stealer that spread itself. Hardening has to cover both.
Lockfiles and npm ci
A committed lockfile is the baseline. Your CI should install exactly what the lockfile says and fail if package.json and the lockfile disagree. That is what npm ci does. npm install in CI can resolve new versions within your semver ranges, which is precisely how a freshly published malicious patch release arrives.
The same applies to Dockerfiles. RUN npm install in an image build is a common gap.
Treat lockfile changes as code. A pull request that changes hundreds of lockfile lines deserves at least a glance at which packages moved and why.
Pinning
Semver ranges like ^5.3.0 are convenient, but they mean "accept any future minor or patch release". With a lockfile and npm ci, ranges only matter when the lockfile is regenerated, but that happens more often than people think: a developer running npm install locally, or a bot refreshing the lockfile.
For applications (not libraries you publish), I pin direct dependencies to exact versions and let the update bot propose changes. Setting save-exact=true in .npmrc makes that the default for new dependencies. Transitive dependencies stay controlled by the lockfile.
Install Scripts
Shai-Hulud ran through a postinstall script. Lifecycle scripts run arbitrary code with the permissions of whoever runs the install, on laptops and in CI.
# .npmrc
save-exact=true
ignore-scripts=trueWith ignore-scripts=true, npm won't run dependency lifecycle scripts. Some packages genuinely need them (native modules such as sharp or bcrypt, or tools that download a binary). You then run those builds explicitly, for example with npm rebuild <package> as a deliberate step, and keep the list short and reviewed. Note that this setting also stops your own project's prepare and postinstall scripts, so check nothing in your workflow depends on them.
pnpm has made this the default since version 10: dependency lifecycle scripts don't run unless the package is listed in onlyBuiltDependencies. It is one of the stronger arguments for pnpm I've seen.
Update Cadence and Cooldowns
Both September attacks were caught within hours to days. A short delay before adopting a new version would have avoided them entirely, at almost no cost.
| Tool | Setting | Effect |
|---|---|---|
| Renovate | minimumReleaseAge |
Waits until a release is a given age before raising the pull request |
| Dependabot | cooldown (available since July 2025) |
Minimum age before version updates, configurable by semver level |
| pnpm (10.16+) | minimumReleaseAge |
Refuses to install versions newer than the set age, in minutes |
# .github/dependabot.yml
version: 2
updates:
- package-ecosystem: "npm"
directory: "/"
schedule:
interval: "weekly"
cooldown:
default-days: 7
groups:
minor-and-patch:
update-types: ["minor", "patch"]{
"extends": ["config:recommended"],
"minimumReleaseAge": "7 days",
"internalChecksFilter": "strict"
}Security fixes are the exception. Dependabot's cooldown applies to version updates, not security updates, and in Renovate you can relax the age for vulnerability alerts. Weekly batches with a seven-day cooldown is the balance I've settled on.
Provenance and Trusted Publishing
If you publish packages, stop using long-lived npm tokens in CI. Trusted publishing, generally available on npm since July 2025, lets GitHub Actions or GitLab CI publish using a short-lived OIDC credential tied to a specific repository and workflow. There is no token to steal, which is exactly what Shai-Hulud relied on. Provenance attestations are generated automatically.
# .github/workflows/publish.yml
name: Publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "22"
registry-url: "https://registry.npmjs.org"
- run: npm install -g npm@latest
- run: npm ci
- run: npm test
- run: npm publishYou configure the trusted publisher (repository and workflow file) in the package settings on npmjs.com. Trusted publishing needs a recent npm CLI, hence the upgrade step. As a consumer, npm audit signatures verifies registry signatures and provenance attestations for what you've installed.
Token Hygiene and 2FA
- Use phishing-resistant 2FA (security keys or passkeys) on npm and GitHub. TOTP codes can be phished through a proxy page, as the September attack showed.
- Require 2FA for publishing on your organisation's packages, and remove classic tokens.
- Treat any "your account will be locked" email as suspicious. Go to the site directly rather than following the link.
- Audit who can publish. Former contractors with publish rights are a common gap.
Scanning
npm audit only knows about disclosed vulnerabilities, so it is necessary but not sufficient. Snyk and similar tools add a richer vulnerability database and fix pull requests. Socket takes a different angle, analysing package behaviour (new install scripts, network access, obfuscated code) and can flag a malicious release before any advisory exists. For supply chain attacks specifically, behavioural analysis is what catches the new thing.
CI Isolation and Secrets
Assume that one day a malicious package will run in your CI. Limit what it can reach.
- Split install and test jobs from deploy jobs. The job that runs
npm cishould not hold deployment credentials. - Use OIDC to assume cloud roles with narrow permissions instead of storing long-lived cloud keys as CI secrets.
- Set
permissionsexplicitly in GitHub Actions workflows, defaulting tocontents: read. - Pin third-party actions to a commit SHA.
- Keep developer machines tidy too: no long-lived production keys in
~/.aws/credentialsor shell profiles.
What to Do by Stage
| Stage | Priorities |
|---|---|
| Startup | Lockfile plus npm ci, phishing-resistant 2FA, ignore-scripts, Dependabot or Renovate with a cooldown. |
| Scaleup | Behavioural scanning on pull requests, exact pinning, separate install and deploy jobs, OIDC for cloud access, trusted publishing for internal packages. |
| Enterprise | An internal registry proxy with policy, allow-lists for install scripts, SBOMs, and an incident runbook for "a dependency was compromised". |
The Takeaway
September 2025 showed that npm attacks now target both fresh installs and the credentials on the machines doing the installing. The defences are mostly configuration: npm ci with a committed lockfile, install scripts off by default, a cooldown before adopting new versions, trusted publishing instead of tokens, and CI jobs that hold no secrets they don't need. None of it is difficult, and all of it is cheaper than rotating every credential your build system has ever seen.
