In September 2025, a phishing email from a lookalike domain — npmjs.help, registered three days earlier — tricked one prolific maintainer into handing over his npm account. Within hours, malicious versions of chalk, debug, ansi-styles and fifteen other packages were live on the registry. Together those packages see on the order of two billion downloads a week. The payload was a browser-side crypto-clipper that silently rewrote wallet addresses in web3 transactions. Days later came Shai-Hulud: a self-replicating worm that used a compromised package's postinstall script to harvest npm tokens and cloud credentials off developer machines and CI runners, then used those tokens to publish trojaned versions of every package the victim maintained. It burned through more than 500 packages — including @ctrl/tinycolor at roughly two million weekly downloads — and a second wave hit again in November.
None of this was exotic. No zero-days, no nation-state tradecraft against the registry itself. Just phishing, install scripts, and the transitive trust every JavaScript project extends to hundreds of strangers by default.
We build and ship Node and Next.js apps for clients. We can't promise anyone that npm will never be attacked again — the opposite is guaranteed. What we can do is make sure the next compromised package doesn't reach a production build, doesn't execute on our machines, and doesn't walk off with credentials that matter. This is the playbook we use, sized for small teams without a security department.

Know the shapes of the attack
Supply-chain attacks on npm cluster into a handful of recurring patterns. If you can name them, you can defend against them, because each one has a specific countermeasure.
Maintainer account takeover
This is the dominant pattern of the last few years, and it's the one behind the incidents above. The attacker doesn't touch your code or the registry's infrastructure — they compromise the human who publishes a package you already trust, then ship malware as a routine patch release.
The lineage is long. event-stream (2018) is the canonical case: a burned-out maintainer handed publish rights to a helpful stranger, who added a dependency (flatmap-stream) carrying an encrypted payload targeting a specific Bitcoin wallet app. eslint-scope (2018) shipped a credential stealer after a maintainer's npm token leaked. ua-parser-js (2021) was hijacked to drop cryptominers and password stealers. The 2025 wave — chalk/debug via phishing, Shai-Hulud via stolen tokens that made the attack self-propagating — was the same idea, industrialized.
Key property: the package name is legitimate, the version resolves from your existing semver ranges, and npm install on a Tuesday morning is all it takes. The defenses are lockfiles, install-script controls, and update delay — all covered below.
Typosquatting and lookalike packages
The attacker publishes crossenv hoping you meant cross-env, or a plausible-sounding scoped package shadowing a popular one. It works because it only needs one tired developer running one hasty npm install. The defense is boring discipline: copy install commands from the package's own repository or docs, not from memory, and distrust low-download packages imitating famous names.
Malicious install scripts
npm runs a package's preinstall/install/postinstall lifecycle scripts automatically, with your full user privileges, at install time — before you've run a single line of your own code. That's the detonator in most modern attacks: Shai-Hulud's entire propagation loop lived in a postinstall script that ran TruffleHog against the host, scooped up npm tokens, .env files, and cloud credentials, and planted GitHub Actions backdoors for persistence.
Internalize this: installing a malicious package is compromise, even if you never import it. It's the single most fixable weakness in the ecosystem, and it gets a whole section below.
Dependency confusion
Alex Birsan demonstrated this in 2021 against dozens of large companies: if your internal package normally comes from a private registry but a public package with the same name exists at a higher version, misconfigured tooling can happily install the public one. If you use internal packages at all, scope everything (@yourorg/*), bind the scope to your registry explicitly in .npmrc, and claim your org's scope on the public registry so nobody else can.
Maintainer sabotage
In January 2022 the maintainer of colors and faker intentionally shipped versions that broke consuming applications — infinite loops and garbage output — as a protest. Not malware in the credential-stealing sense, but a reminder that "trusted maintainer" is a statement about the past, not the future. The mitigations are the same as for takeover: you want a delay and a diff between "new version exists" and "new version runs on our machines."
Lockfiles are a security boundary — treat them like one
A lockfile is not just a reproducibility convenience. It's the artifact that says exactly which bytes, by integrity hash, your project depends on. Everything else in this post builds on it.
The rules:
- Commit
package-lock.json. Always, including for libraries' development flows. An untracked lockfile means everynpm installis a fresh negotiation with the registry. - Use
npm cieverywhere that isn't a human deliberately changing dependencies. CI, Docker builds, deployment platforms, onboarding scripts.npm ciinstalls exactly what the lockfile specifies, verifies integrity hashes, and fails ifpackage.jsonand the lockfile disagree — instead of "helpfully" resolving new versions the waynpm installwill. - Review lockfile diffs in PRs. A 4,000-line lockfile diff attached to a one-line feature is a smell. You don't need to read every hunk, but you should notice when a dependency bump pulls in a new transitive package or a
resolvedURL pointing somewhere that isn't the registry you expect.
The threat model is real: during the chalk/debug window, projects with committed lockfiles and npm ci in CI were simply not exposed unless they actively updated during the few hours the bad versions were live. Projects running bare npm install with ^ ranges picked up the malicious patch versions automatically. Same dependencies, opposite outcomes — the difference was one command.
One more tool worth knowing: overrides in package.json lets you force a transitive dependency to a known-good version when a package deep in your tree is compromised and the intermediate maintainers haven't reacted yet.
{
"overrides": {
"compromised-transitive-pkg": "2.4.1"
}
}
Shrink the surface before you defend it
Every dependency is a maintainer account that can be phished, a repo that can be hijacked, and an install script that can run on your machines. The cheapest security win available is having fewer of them.
Our working rules on client projects:
- Price the transitive cost, not the direct one.
npm ls --all | wc -lbefore and after adding a package is a two-second habit. A "small" utility that brings 80 transitive packages is not small. - Prefer the platform.
fetch,structuredClone,Intl,node:crypto,node:test— many 2016-era dependency staples are language and runtime features now. - Vendor trivial things. Thirty lines of code you fully understand, copied into
lib/with attribution, beats a dependency whose every future release you'll trust sight-unseen. - Audit what's actually used.
npx knip(ordepcheck) finds dependencies nobody imports anymore. Dead dependencies are pure risk with zero payoff. - Weight maintenance signals. Provenance-attested releases, 2FA-enforced orgs, and multiple maintainers matter more than star count.
This isn't dependency asceticism — we use plenty of packages. But "someone already wrote this" stopped being a sufficient reason to install something around the time install scripts started stealing cloud credentials.
Turn off install scripts (and know what breaks)
If you adopt one thing from this post, adopt this. In the repository's .npmrc:
ignore-scripts=true
With that set, npm install and npm ci will not execute any package's lifecycle scripts. The Shai-Hulud propagation mechanism — and the payload delivery for a large share of malicious packages ever published — simply doesn't fire. The code still lands on disk, but it doesn't run until you import it, which for a build-time compromise window of a few hours usually means it never runs at all.
Now, the honest part: this breaks things, and you need to know which things.
- Native addons that compile with
node-gypat install time won't build. Much of the ecosystem has moved to prebuilt binaries shipped asoptionalDependencies(modernesbuildandsharpwork this way), but older native packages will fail at require-time with a missing-binding error. - Packages that download binaries in
postinstall— some browser-automation and CLI tools — will install "successfully" and then fail when invoked, which is more confusing than a loud install error. - Your own lifecycle scripts stop too. This is the gotcha that surprises people:
ignore-scriptsalso disables yourprepare,prepack, andpostinstall. If you usehuskyviaprepare, hooks won't install; if you generate code inpostinstall, it won't run.
The pattern that works: ignore scripts globally, then explicitly run the few things you actually need.
{
"scripts": {
"postinstall:hooks": "husky",
"setup": "npm ci && npm run postinstall:hooks"
}
}
Team members run npm run setup once after cloning; CI runs npm ci and never needs the hooks. If a specific dependency genuinely requires its build script, run it deliberately with npm rebuild <pkg> after install, so the allowlist is visible in your pipeline rather than implicit in registry metadata.
pnpm makes this cleaner still: since v10 it refuses to run dependency lifecycle scripts unless the package is allowlisted in onlyBuiltDependencies — a default that spared many pnpm users during the 2025 wave, and a good reason to consider it for new projects.
Provenance, attestations, and signatures
For years, an npm package was just a tarball someone uploaded with a token — no cryptographic link between the code on GitHub and the code on the registry. That's finally changing.
Provenance attestations (Sigstore-backed, published with npm publish --provenance from a supported CI system like GitHub Actions) bind a published version to the exact source repository, commit, and workflow that built it. A package with provenance can't be quietly republished from an attacker's laptop with a stolen token without the attestation being absent or mismatched — which is precisely the tell in a takeover attack.
Trusted publishing goes further: the registry accepts publishes via short-lived OIDC credentials from your CI provider instead of long-lived npm tokens. No token to phish, no token to exfiltrate from a .npmrc. After Shai-Hulud, npm moved aggressively in this direction — tightening token lifetimes and pushing maintainers toward trusted publishing and phishing-resistant 2FA. If you publish packages, even internal ones, set this up; it eliminates the exact credential class the worm fed on.
As a consumer, make verification part of CI:
npm ci
npm audit signatures
npm audit signatures checks registry signatures and provenance attestations for your installed tree. It won't catch a malicious version that was legitimately published from a compromised account's CI — nothing fully does — but it kills the stolen-token republish path and costs seconds.
Pin GitHub Actions by commit SHA
Your CI workflow is a dependency tree too, and it usually holds better credentials than your app does. A mutable tag like uses: some-org/some-action@v3 means the action's maintainer — or whoever compromises them — can repoint that tag at malicious code that runs in your workflow with access to your secrets. Retagging compromised actions to steal CI secrets is an established, repeatedly observed attack.
Pin every third-party action to a full commit SHA, with the human-readable version in a comment:
jobs:
build:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
- uses: actions/setup-node@39370e3970a6d050c480ffad4ff0ed4d3fdee5af # v4.1.0
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run lint && npm run build
Dependabot understands the comment convention and will bump both the SHA and the comment together, so pinning doesn't mean freezing.
While you're in the workflow file, apply least privilege to the runner itself:
- Set
permissions:explicitly — the defaultGITHUB_TOKENgrant is broader than most jobs need;contents: readis enough for a lint-and-build gate. - Don't expose deployment or cloud secrets to jobs that only lint and test. Secrets absent from the environment can't be exfiltrated by a malicious
postinstall. - Prefer OIDC federation to your cloud provider over long-lived keys stored as repository secrets.
An update strategy that doesn't auto-merge malware
The uncomfortable tension: standard advice says "update dependencies promptly," but every 2025-era registry attack was distributed as an update. The projects hit hardest were the ones that updated fastest — auto-merge bots dutifully shipping the compromised patch release to production.
The resolution isn't to stop updating; stale dependencies have their own CVE problem. It's a cooldown: never install a version in its first days of existence. Malicious releases of popular packages are typically detected and yanked within hours to days — the chalk/debug versions lasted about two hours. A one-week delay filters out nearly all of them, at essentially zero cost, because you almost never need a patch release within days of publication.
With Renovate:
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"extends": ["config:recommended"],
"minimumReleaseAge": "7 days",
"packageRules": [
{
"matchUpdateTypes": ["patch", "minor"],
"matchCurrentVersion": "!/^0/",
"automerge": false
}
]
}
Dependabot grew an equivalent cooldown setting; pnpm can even enforce a minimumReleaseAge at the package-manager level, which covers human installs too, not just bot PRs.
The rest of our update policy:
- No blanket auto-merge. A human glances at every dependency PR; on a weekly batch cadence that's minutes, not hours.
- Notice the takeover fingerprints. New maintainer, moved repository, a patch release that adds a
postinstallscript or new dependencies — Renovate PRs surface most of these. - Security advisories override the cooldown. A real CVE in something you ship gets a deliberate, prompt update — a considered action, not a bot reflex.
SBOMs: know what you're running before you need to know
When the next big incident hits, the first question is always "are we affected?" — and teams without an inventory spend the worst hours of the incident grepping lockfiles across repos. A Software Bill of Materials makes that question a query.
npm can generate one natively:
npm sbom --sbom-format cyclonedx > sbom.cyclonedx.json
Generate it in CI on every production build and store it as a build artifact. When an advisory names some-pkg@4.2.1, you check the SBOMs of what's actually deployed, not the head of main (--sbom-format spdx exists if compliance asks). Pair it with a scanner — npm audit at minimum, OSV-Scanner if you can — so known-bad versions get flagged without anyone remembering to look.
For a small studio this sounds like enterprise theater, but it's one CI step, and it turns "are we exposed?" from a five-hour grep across repos into a five-minute query.
When a dependency is compromised: assume the secrets walked
Suppose a package you installed last week turns out to be malicious. The code question ("did it reach production?") matters, but the credential question matters more, because install-script malware exists primarily to steal secrets. Shai-Hulud didn't care about your app; it cared about your npm token, your AWS_* environment variables, and your GitHub credentials.
The posture that limits the damage is set up before the incident:
- Keep secrets out of install-time reach. A
postinstallscript can read the environment and filesystem wherever it runs. So: no production secrets in developer.envfiles, and CI secrets scoped per-job as above. - Prefer short-lived, federated credentials. OIDC from CI to AWS/GCP/Vercel means there's no long-lived key to steal from the runner. Tokens that expire in an hour make the exfiltrated haul nearly worthless.
- Know your rotation drill. For each credential class — npm tokens, cloud keys, GitHub PATs, database URLs, third-party API keys — write down where it lives and how to rotate it. Rotation you have to research during an incident happens too late.
When it happens: remove and pin away the bad version, rotate every credential present in any environment where the install ran — assume full env and home-directory access — then audit for persistence. Shai-Hulud planted GitHub Actions workflow backdoors and created rogue repositories, so "delete node_modules" is not remediation; check recent workflow changes, new repos, deploy keys, and OAuth grants on any exposed account.
The hardening checklist
The whole playbook, in the order we'd apply it to a repo today:
| # | Action | Effort |
|---|---|---|
| 1 | Commit the lockfile; npm ci in CI, Docker, and deploy — never bare npm install | Minutes |
| 2 | ignore-scripts=true in .npmrc; explicit setup script for the few hooks you need | An hour, plus fixing stragglers |
| 3 | Add npm audit signatures to CI after install | Minutes |
| 4 | Pin all GitHub Actions to full commit SHAs; set explicit permissions: | An hour |
| 5 | Configure Renovate/Dependabot with a 7-day cooldown; disable blanket auto-merge | An hour |
| 6 | Scope CI secrets per-job; move cloud auth to OIDC where supported | Half a day |
| 7 | Generate an SBOM per production build in CI | Minutes |
| 8 | Run knip/depcheck; remove dead dependencies; question the heaviest ones | Ongoing |
| 9 | If you publish packages: trusted publishing + provenance, phishing-resistant 2FA | Half a day |
| 10 | Write the credential-rotation runbook before you need it | An afternoon |
Items 1–4 fit in a single afternoon — and would have neutralized most of the 2025 wave for a typical consumer of the affected packages.
Takeaways
- Modern npm attacks overwhelmingly ride maintainer account takeover and install scripts — legitimate package names, malicious patch releases, payloads that fire at
npm installtime. - Installing is executing.
ignore-scripts=trueis the highest-leverage single control in the ecosystem; learn its failure modes (native builds, binary downloads, your ownpreparehooks) and adopt it anyway. - Lockfile +
npm ciis a security boundary, not a convenience. Barenpm installin CI with^ranges is how compromised patch versions reach you automatically. - Update speed is not a virtue by itself. A 7-day cooldown in Renovate/Dependabot filters out nearly every malicious release — typically detected within hours or days — at no practical cost.
- Your CI is a supply chain too. Pin actions by SHA, set explicit token permissions, scope secrets per-job, prefer OIDC over stored keys.
- Fewer dependencies beats better scanning. Every package you don't install is a phishable maintainer you don't trust.
- Assume compromise steals credentials, not just code. Short-lived federated credentials and a written rotation runbook determine whether an incident is an afternoon or a month.
- None of this requires a security team. Most of it is an afternoon of config, and the attacks it stops are the ones actually happening.