Engineering6 min read

npm Supply Chain Attacks in 2025: Hardening a Node.js and TypeScript Codebase

In September 2025 a phished maintainer led to malware in chalk and debug, then a self-replicating worm spread through hundreds of npm packages. Here's what happened and how I'm hardening our Node.js codebases.

Gopal Yendluri
Contents
  1. A Bad Month for npm
  2. Lockfiles and npm ci
  3. Pinning
  4. Install Scripts
  5. Update Cadence and Cooldowns
  6. Provenance and Trusted Publishing
  7. Token Hygiene and 2FA
  8. Scanning
  9. CI Isolation and Secrets
  10. What to Do by Stage
  11. The Takeaway

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=true

With 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 publish

You 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 ci should not hold deployment credentials.
  • Use OIDC to assume cloud roles with narrow permissions instead of storing long-lived cloud keys as CI secrets.
  • Set permissions explicitly in GitHub Actions workflows, defaulting to contents: read.
  • Pin third-party actions to a commit SHA.
  • Keep developer machines tidy too: no long-lived production keys in ~/.aws/credentials or 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.

npmsupply-chain-securityNode.jsTypeScriptCI-CDsecurity