Back

A Simple Defense Against npm Supply Chain Attacks

A Simple Defense Against npm Supply Chain Attacks

Every time you run npm install, you’re trusting code from hundreds of packages you’ve never read. That trust is exactly what attackers exploit. In the September 2025 Shai-Hulud worm, malicious install-time scripts silently harvested credentials and republished compromised packages at scale—all before the command prompt returned. The March 2026 Axios compromise followed the same pattern, where malicious versions (1.14.1 and 0.30.4) pulled in a dependency with a postinstall hook that executed a remote access payload.

You don’t need heavy tooling to reduce this exposure. One configuration change addresses the most common attack vector directly.

Key Takeaways

  • npm lifecycle scripts (preinstall, install, postinstall, prepare) execute automatically with full user privileges, making them the lowest-friction path for supply chain attacks.
  • Setting ignore-scripts=true in .npmrc blocks the most common attack vector with a single line of configuration.
  • Native-binary packages such as sharp, bcrypt, and sqlite3 can still be built selectively using npm rebuild after a script-free install.
  • A CI check on package-lock.json for new "hasInstallScript": true entries flags dependencies that require review before they merge.
  • Pairing ignore-scripts=true with min-release-age=7 adds a delay that helps avoid freshly compromised package versions.

Why Lifecycle Scripts Are the Core Risk

When you install an npm package, Node runs any scripts defined in its package.json automatically—preinstall, install, postinstall, and prepare. These hooks execute with your full user privileges, granting access to ~/.ssh, ~/.aws/credentials, ~/.npmrc tokens, and every environment variable in your shell.

No confirmation prompt. No sandbox. Just silent execution.

In the Axios attack, a malicious transitive dependency called plain-crypto-js used a postinstall hook to drop a remote access trojan. In the Bitwarden CLI impersonation attack, a preinstall hook bootstrapped a large obfuscated payload that harvested cloud credentials and attempted to propagate via the victim’s npm publishing access.

Lifecycle script security matters because it represents the lowest-friction attack path in the entire JavaScript dependency security landscape.

The Core Defense: npm ignore-scripts

Add one line to your project’s .npmrc:

ignore-scripts=true

This tells npm to skip all lifecycle scripts during installation. A malicious postinstall hook simply doesn’t run.

For a one-time install without modifying .npmrc:

npm install --ignore-scripts

Important caveat: This isn’t a complete solution. The Bitwarden attack registered a malicious binary via the bin field in package.json, meaning the payload still executed when a user invoked the bw command—even with --ignore-scripts active. No single flag eliminates all risk.

What Breaks and How to Handle It

Some legitimate packages require lifecycle scripts to compile native binaries. Common ones include sharp, bcrypt, sqlite3, canvas, and puppeteer (which downloads Chromium).

Check whether your project uses any of them:

npm ls --all | grep -E "(sharp|bcrypt|sqlite3|canvas|puppeteer|node-gyp)"

If you get matches, rebuild only those specific packages after installing:

npm install --ignore-scripts
npm rebuild sharp --ignore-scripts=false
npm rebuild bcrypt --ignore-scripts=false

This approach keeps scripts disabled globally while selectively allowing compilation for packages you’ve explicitly reviewed.

Detecting New Install Scripts Before They Run

Rather than reacting after the fact, flag new dependencies with install scripts before they merge. Add this check to your CI pipeline:

# Detect newly added packages with install scripts in PRs
if git diff origin/main -- package-lock.json | \
     grep -E '^\+\s*"hasInstallScript": true' > /dev/null; then
  echo "⚠️ New install script detected—manual review required"
  exit 1
else
  echo "✅ No new install scripts"
fi

In the Axios attack, plain-crypto-js was a brand-new dependency with a postinstall hook. This check would have flagged it before the PR merged.

For details on how npm records install scripts in lockfiles, see the official package-lock.json documentation.

What These Defenses Don’t Cover

Be honest about the limits:

  • Lockfiles don’t prevent attacks if a developer ran npm install during the compromise window and poisoned the lockfile before you caught it.
  • npm audit only catches known malicious packages—novel attacks won’t appear in its database.
  • 2FA on npm accounts doesn’t protect consumers from a package legitimately published by a compromised maintainer.
  • --ignore-scripts doesn’t block malicious code embedded in the package’s runtime JavaScript itself.

A layered approach helps. Pair ignore-scripts=true with min-release-age=7 in .npmrc (requires npm v11.10.0 or later) to avoid installing packages published in the last week—the window when most attacks are active and undetected. This setting is documented in the npm config docs under min-release-age.

Start Here

Add this to your .npmrc today:

ignore-scripts=true
min-release-age=7

Then add the CI check for new hasInstallScript entries on every PR that touches package-lock.json. These two changes address the attack pattern used in recent npm supply chain attacks—without requiring new tools, paid services, or significant workflow changes.

Conclusion

You can’t read every line of every package you depend on, but you can sharply limit what those packages are allowed to do during installation. Disabling lifecycle scripts by default, rebuilding only the native packages you trust, and flagging new hasInstallScript entries in CI together neutralize the most heavily exploited attack vector in the npm ecosystem. None of these measures require new vendors or budget—just a few lines in .npmrc and a single CI check. Adopt them today, and the next Shai-Hulud-style attack becomes far less impactful for your project.

FAQs

It may break packages that compile native binaries during installation, such as sharp, bcrypt, sqlite3, canvas, and puppeteer. After running npm install with scripts disabled, use npm rebuild followed by the package name with --ignore-scripts=false to compile only the trusted packages you've reviewed. Most pure-JavaScript dependencies will work without any changes.

No. It blocks the most common vector—lifecycle hooks like postinstall—but won't stop malicious code in a package's runtime JavaScript or a malicious binary registered through the bin field. The Bitwarden CLI impersonation attack bypassed ignore-scripts this way. Combine it with min-release-age, lockfile review, and dependency auditing for layered defense.

The min-release-age setting requires npm version 11.10.0 or later. Check your version with npm --version and upgrade if needed using npm install -g npm@latest. Verify the setting is active using npm config get min-release-age after configuring it.

Commit your .npmrc file with ignore-scripts=true to the repository so CI inherits the setting automatically. For builds that genuinely need native compilation, add explicit npm rebuild commands (with --ignore-scripts=false) for the specific packages required. This keeps the secure default in place while documenting exactly which packages are permitted to run installation code.

Gain control over your UX

See how users are using your site as if you were sitting next to them, learn and iterate faster with OpenReplay. — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay