The SolarWinds breach of 2020 demonstrated that the most effective way to compromise a large organization is not to attack it directly — it is to attack the software it trusts and installs. In 2026, the npm ecosystem is the most active attack surface for supply chain attacks targeting web developers, with new documented attacks occurring monthly.
Two attack types dominate: typosquatting (registering lodahs hoping someone typos lodash) and dependency confusion (a more sophisticated technique that exploits how npm resolves package names between public and private registries).
How Dependency Confusion Works
Most companies use a private npm registry (Artifactory, GitHub Packages, Verdaccio) for internal packages:
// company's package.json
{
"dependencies": {
"@mycompany/internal-ui": "2.1.0", // Private package
"react": "18.0.0" // Public package
}
}The attack:
- Attacker discovers the internal package name
@mycompany/internal-ui(from job postings, leaked package.json files, or error messages). - Attacker publishes a malicious package with the same name to the public npm registry.
- If the private registry is not configured correctly, npm resolves the public version (often a higher version number) instead of the private one.
- The malicious package runs in
postinstallscripts with full system access.
DEPENDENCY CONFUSION:
npm install
│
├── Check private registry first? → @mycompany/internal-ui: v2.1.0
│ │
│ OR (if misconfigured): │
│ │
└── Check public registry first? → @mycompany/internal-ui: v9.9.9 ← ATTACKER'S PACKAGE
Alex Birsan's 2021 research demonstrated this attack worked against Apple, Microsoft, Netflix, Tesla, and Uber. Most were not using scoped private packages correctly.
Defense 1: Configure Private Registry Correctly
# .npmrc — force all @mycompany scoped packages to your private registry
@mycompany:registry=https://npm.pkg.github.com
//npm.pkg.github.com/:_authToken=${NPM_TOKEN}
# CRITICAL: never allow public registry fallback for private packages
# In Artifactory: disable "Remote repositories" for your internal scope
# In GitHub Packages: scoped packages are automatically private-onlyIn Verdaccio (self-hosted):
# config.yaml
packages:
'@mycompany/*':
access: $authenticated
publish: $authenticated
# No 'proxy' entry = no public registry fallback for this scope
'**':
access: $all
publish: $authenticated
proxy: npmjs # Public packages still fall through to npmjsDefense 2: Lock All Dependency Versions
Never use ^ (caret) or ~ (tilde) version ranges for security-sensitive dependencies. Use exact versions:
{
"dependencies": {
"@mycompany/internal-ui": "2.1.0", // Exact — safe
"express": "4.18.2", // Exact — safe
"lodash": "^4.17.0" // Range — allows 4.18.0 (potentially malicious)
}
}And always use --frozen-lockfile in CI:
- name: Install dependencies
run: pnpm install --frozen-lockfile
# Fails if package-lock.json doesn't match package.json
# Prevents any unexpected version resolution
Defense 3: Automated Package Scanning
# .github/workflows/supply-chain-security.yml
name: Supply Chain Security
on:
push:
branches: [main]
pull_request:
jobs:
scan:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# Standard CVE database check
- name: npm audit
run: npm audit --audit-level=high
# Socket.dev: detects behavioral anomalies (install scripts, obfuscation)
- name: Socket Security Scan
uses: SocketDev/socket-security-action@v1
with:
api-key: ${{ secrets.SOCKET_API_KEY }}
allow-new-deps: false # Require explicit approval for new packages
# Snyk: dependency vulnerability scanning
- name: Snyk Security Scan
uses: snyk/actions/node@master
env:
SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
with:
args: --severity-threshold=highSocket.dev specifically addresses supply chain attacks by analyzing behavioral signals — not just CVE databases:
- Does the package run install scripts? (Most legitimate packages don't.)
- Does it obfuscate its code?
- Does it make network requests during installation?
- Was it recently published by a new maintainer (common in account hijacking)?
Defense 4: Subresource Integrity for CDN Scripts
If you load scripts from CDNs, use Subresource Integrity (SRI) hashes:
<!-- Without SRI: trusting the CDN completely -->
<script src="https://cdn.example.com/lodash.min.js"></script>
<!-- With SRI: browser verifies the file hash before executing -->
<script
src="https://cdn.example.com/lodash.min.js"
integrity="sha384-Vkoo8x4CGsO3+Hhxv8T/Q5PaXtkKtu6ug5TOeNV6gBiFeWPGFN9MuhOf23Q9Iio"
crossorigin="anonymous">
</script>Generate SRI hashes:
curl -s https://cdn.example.com/lodash.min.js | openssl dgst -sha384 -binary | openssl base64 -A
Defense 5: Allowlist Approved Packages
For high-security environments, maintain an explicit allowlist of approved packages and fail CI on any unlisted dependency:
// scripts/check-approved-packages.js
import { readFileSync } from 'fs';
const APPROVED_PACKAGES = new Set([
'react', 'react-dom', 'next', 'typescript',
// ... your approved list
]);
const packageJson = JSON.parse(readFileSync('package.json', 'utf8'));
const allDeps = {
...packageJson.dependencies,
...packageJson.devDependencies,
};
const unapproved = Object.keys(allDeps)
.filter(dep => !dep.startsWith('@mycompany/'))
.filter(dep => !APPROVED_PACKAGES.has(dep));
if (unapproved.length > 0) {
console.error('Unapproved packages detected:', unapproved);
console.error('Add these to the approved list after security review.');
process.exit(1);
}Red Flags to Watch For
Treat these as immediate security concerns in any npm package:
postinstallscripts that execute arbitrary commands.- Network requests during installation (
install.jsthat calls external URLs). - Obfuscated code (minified in source files, base64-encoded strings).
- Unusually high version numbers for packages you don't recognize (dependency confusion indicator).
- New maintainer on an old, popular package (account hijacking).
- Package name closely resembles a popular package (
colursvscolors,loadshvslodash).
Conclusion
Supply chain attacks through npm are not theoretical — they have compromised some of the world's largest organizations. The defense is layered: configure your private registry to prevent public fallback for internal package names, use exact version locking in CI, scan every PR for behavioral anomalies with Socket.dev, and use --frozen-lockfile to prevent unexpected resolution changes. The 30 minutes it takes to implement this stack is a small investment against the cost of a supply chain breach.