Context
Hackers are hijacking npm packages at an alarming rate. We can't stop maintainer accounts from being compromised, but we can stop being easy targets. In today's ecosystem, the burden of proof has shifted: security is now the responsibility of the person hitting npm install.
It's probably fine → Prove it's safe
Most devs treat weekly or monthly download counts as a sign of reliability — evidence that a package is well-maintained and solves a real problem. That's reasonable, up to a point. But once a hacker hijacks a maintainer's account and tampers with the package or adds a postinstall script, that download count doesn't reset.
All a hijacker needs is a single line in package.json to exfiltrate your ~/.ssh keys or .env file — in a 2-second install.
What allows this to happen over and over?
- Insecure defaults — the tooling runs arbitrary code on install without asking
- Blind trust — there's no decent way to verify you're getting exactly what you asked for, nothing more
Securing yourself comes down to two things: tightening insecure defaults to a sane configuration, and having a better way to verify what you're actually installing.
Zero trust
Using a runtime with secure defaults is the nuclear option — the most thorough way to address both problems at once. The two runtimes worth knowing here are Deno and Bun.
Deno is the gold standard for security. Its "secure by default" approach grants no permissions to packages unless explicitly allowed. There's no concept of postinstall scripts at install time — the first time any package code executes is when you explicitly run your application. Deno also has built-in Subresource Integrity (SRI): it checks the checksum of every remote module, and if a hacker tampers with even a single character, there's a hash mismatch and Deno refuses to run it.
Deno also pushes the use of JSR (the JavaScript Registry) alongside npm. JSR uses OpenID Connect (OIDC), so maintainers don't have to store long-lived secret tokens that could be stolen — directly addressing the blind trust problem at the publishing layer.
The one caveat: Deno has first-class ESM support but limited CommonJS support, which could be a problem for legacy codebases.
Middle ground
If Deno is too steep a migration, Bun is a reasonable alternative. It supports CommonJS modules and npm packages, but unlike npm, it does not execute lifecycle scripts like postinstall by default. Packages that legitimately need them must be explicitly listed:
{
"name": "my-app",
"version": "1.0.0",
"trustedDependencies": ["my-trusted-package"]
}
If switching runtimes isn't on the table, there's still one more option — switching package managers. It's a much smaller ask.
pnpm
pnpm v10 made a deliberate shift to "secure by default." It no longer runs preinstall or postinstall scripts implicitly, eliminating the same class of attacks Bun protects against — just without changing your runtime.
It also introduced a trustPolicy setting that, when set to no-downgrade, refuses to install a package version whose trust level is weaker than previous releases.
Concretely: if a package was previously published via OIDC and a new version suddenly isn't, pnpm flags it. That's a direct answer to the blind trust problem.
Yarn
Yarn's main security contribution is checksum verification baked into yarn.lock — every package gets a SHA-512 hash, so if anything is tampered with between the registry and your machine, the install fails. It's a narrower guarantee than pnpm offers today, but still a meaningful improvement over npm's defaults.
Last resort — harden your .npmrc
If you can't change your runtime or package manager, at minimum fix your npm config. The defaults are not safe.
# Kills the primary attack vector — no postinstall/preinstall scripts
ignore-scripts=true
# Git-based deps can ship their own .npmrc to re-enable scripts — this closes that escape hatch
allow-git=none
# Hijacked packages are usually caught within days — this gives the community time to respond
min-release-age=3
# Ensures package-lock.json is never silently bypassed or auto-updated
save-exact=true
package-lock=true
# Catches known CVEs — won't stop novel supply chain attacks, but worth having
audit=true
audit-level=high
# Enforce HTTPS only
# registry=https://registry.npmjs.org/
Final thoughts
A TL;DR of everything above:
- Use secure runtimes — Deno is the best option, Bun is a solid middle ground
- Use a better package manager — pnpm v10 gives you Bun-level script safety without changing your runtime
- Harden your npm config — if nothing else, ignore-scripts=true alone would have blocked most of the high-profile attacks from the past two years
The common thread across all of these is the same: stop trusting the install process by default. The attacks keep working because the tooling keeps cooperating.
Top comments (0)