Supply-chain security
Learn how to verify JavaScript dependencies with audit reports, lockfiles, SRI, provenance, name checks, and smaller dependency choices.
- 01Map the chainName the packages, tools, registries, CDNs, and CI systems that can affect production code.
- 02Verify installs and assetsUse audit reports, lockfiles, npm ci, lifecycle-script review, provenance, and SRI for the threats they actually cover.
- 03Reduce the attack surfaceSpot suspicious package names, avoid dependency confusion, and choose fewer, better dependencies or platform APIs.
Trust, but verify
Supply-chain security is the practice of verifying code you did not write before it can run on your laptop, in CI, in a build tool, or in a user's browser. JavaScript makes reuse easy, so professional teams need habits that keep reuse from becoming blind trust.
A software supply chain is every dependency, tool, service, script, CDN, registry, token, and publishing step that can change the code your users eventually run. The lesson's rule is simple: trust, but verify, the code you install.
This lesson builds on npm & package.json for semver, lockfiles, and lifecycle scripts. It also links back to transpilers and polyfills because the 2024 Polyfill.io incident turned a compatibility CDN into a supply-chain warning. You will also see why bundlers, the concurrent Web Crypto lesson, text encoding, and XSS defenses matter here.
A grocery store does not personally grow every ingredient. It chooses suppliers, checks packaging, tracks lots, responds to recalls, and removes products when ownership or safety changes. JavaScript teams do the same with package names, lockfiles, audits, SRI, and release workflows.
- In real life: Farm, shipper, warehouse, and store
- In JavaScript: Maintainers, registries, CDNs, CI, and package managers
- In real life: Ingredient label
- In JavaScript:
package.jsonand release notes - In real life: Sealed package with a lot number
- In JavaScript: Lockfile integrity values and SRI hashes
- In real life: Recall notice
- In JavaScript: Security advisories, Dependabot, Renovate, Snyk, or Socket alerts
Where the analogy stops: Food inspection can sample physical goods. Software can execute instantly and recursively: your dependency can run install scripts, fetch other packages, or ship code to every visitor.
What the supply chain includes
REAL COUNTSThe chain is bigger than the packages you import directly. It includes your direct dependencies, their dependencies, build tools, package registries, CI images, release tokens, lockfiles, CDNs, browser-loaded scripts, and the people or bots allowed to publish any of those pieces.
| Surface | What it means | How to verify it |
|---|---|---|
| Direct dependencies | Packages named in your manifests. | Review names, owners, releases, licenses, scripts, and whether you need them. |
| Transitive dependencies | Dependencies of dependencies. | This repo's root lockfile currently has 611 package entries but only 35 direct dependency names across workspace manifests. |
| Build tools | Bundlers, compilers, minifiers, test tools, and release scripts. | They run with developer or CI permissions before users see your app. |
| CDNs | Scripts, styles, polyfills, fonts, and analytics loaded at runtime. | Use SRI for immutable assets and avoid services that can change bytes per request. |
| CI and registries | Tokens, publish workflows, package registries, artifact stores, and GitHub Actions. | Prefer least-privilege tokens, trusted publishing, secret scanning, and reviewable release automation. |
Transitive dependencies dominate because every direct package can bring its own tree. In this checkout, a script over the root package-lock.json counts 611 package entries excluding the root project, but only 35 unique direct dependency names across the workspace manifests recorded in that lockfile. That gap is why lockfiles, review tools, and fewer dependencies matter.
const fs = require("node:fs");const lock = JSON.parse(fs.readFileSync("package-lock.json", "utf8"));const entries = Object.entries(lock.packages ?? {});const lockfilePackageEntries = entries.filter(([path]) => path !== "").length;const workspaces = entries.filter(([path, entry]) => path !== "" && !path.startsWith("node_modules/") && entry.name);const direct = new Set();for (const [, entry] of workspaces) { for (const bucket of ["dependencies", "devDependencies", "optionalDependencies", "peerDependencies"]) { for (const name of Object.keys(entry[bucket] ?? {})) direct.add(name); }}console.log(lockfilePackageEntries + " lockfile package entries");console.log(direct.size + " direct dependency names across workspace manifests");Direct dependencies are the names your team chose. Transitive dependencies are code you still run because those choices pulled more packages into the graph. Attackers often aim at the deep graph because the package is widely used but rarely reviewed by application teams.
Incidents that shaped the threat model
TIMELINEThese incidents are not trivia. Each one teaches a different failure mode: availability, maintainer transfer, account hijack, protestware, long social engineering, CDN ownership, phishing, and install-time worm behavior.
| Incident | Date | What it teaches |
|---|---|---|
| left-pad | March 22, 2016 | A maintainer unpublished tiny npm packages after a naming dispute; builds broke and npm tightened unpublish policy. |
| event-stream | 2018 | A new maintainer added malicious flatmap-stream code aimed at Copay wallet users; it was disclosed in November 2018. |
| ua-parser-js | October 22, 2021 | A hijacked maintainer account published malware that installed miners and password stealers in compromised versions. |
| colors/faker | January 2022 | A maintainer intentionally sabotaged popular packages, including an infinite loop in colors and broken faker releases. |
| xz-utils | March 2024 | Not npm, but instructive: malicious xz 5.6.0/5.6.1 release tarballs hid an SSH-related backdoor, discovered by Andres Freund. |
| polyfill.io | February-June 2024 | After ownership changed, scripts from cdn.polyfill.io were reported serving conditional malicious JavaScript to sites using the CDN. |
| npm phishing and Shai-Hulud | September 2025 | Researchers reported phishing/token theft followed by a self-replicating npm worm that used install scripts to steal secrets and publish more infected packages, including popular packages such as @ctrl/tinycolor; package counts changed as advisories were updated. |
The xz-utils backdoor was not an npm package, but it is essential for JavaScript developers because build systems rely on operating-system libraries, CI images, and maintainers you may never see. The Shai-Hulud reports matter because a package install can run code before your app starts; stolen npm, GitHub, or cloud credentials can then publish more compromised packages.
Ask how your workflow would respond: Would a lockfile pin the bad version or block drift? Would npm ci reproduce the graph? Would --ignore-scripts help investigate? Would secret scanning catch leaked tokens? Would a CDN hash fail closed? Different incidents need different controls.
npm audit, lockfiles, and lifecycle scripts
COMMANDSnpm audit compares your resolved dependency graph with published advisories. It reports severity, vulnerable paths, patched versions, and sometimes fix commands. It does not know about a brand-new hijack before an advisory exists, and it can be noisy when a vulnerable package is unreachable in your app or only used by development tooling.
npm audit --audit-level=highnpm audit fixnpm audit fix --forcenpm cinpm install --ignore-scriptsUse npm audit fix for reviewed patch-level or compatible updates. Treat npm audit fix --force as a migration, because it may cross major versions or replace packages in ways that require tests and code review. Use npm ci in CI so the lockfile is the source of truth; it refuses drift between package.json and package-lock.json.
Lockfiles do not remove risk. They record exact versions and integrity hashes so the same graph installs again. Pinning every dependency exactly can reduce surprise but makes updates harder to receive. Semver ranges plus a committed lockfile are the common app trade-off: development can update intentionally, CI repeats exactly, and review tools open explicit changes.
- A new dependency adds a
postinstallscript - CI uses
npm cifrom a committed lockfile - The package has many weekly downloads
- A versioned CDN script has a checked
sha384-...integrity value - The package name is
loadshwhen you meantlodash npm auditreports zero vulnerabilities today
Sort each card by what it tells you during a dependency review. Some signals help but are not enough by themselves.
Packages can run preinstall, install, and postinstall scripts. npm install --ignore-scripts is useful when you want to inspect a new dependency without executing its install hooks. It is not a permanent workaround for a package whose legitimate build requires scripts; decide whether the package is worth that trust.
Subresource Integrity
HASH ITSubresource Integrity, or SRI, lets HTML say “download this external file only if its bytes hash to this exact value.” It is most useful for versioned CDN assets that should be immutable. The browser fetches the file, computes the declared hash, compares it with the integrity attribute, and refuses to execute or apply the resource on mismatch.
<script src="https://cdn.example.com/app.v1.js" integrity="sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e" crossorigin="anonymous"></script>Step through how a real SRI hash is produced: bytes, SHA-384, base64, and the sha384- prefix.
script
async function computeSriHash(source) { const bytes = new TextEncoder().encode(source); const digest = await crypto.subtle.digest("SHA-384", bytes); const base64 = btoa(String.fromCharCode(...new Uint8Array(digest))); return `sha384-${base64}`;} computeSriHash(file).then(console.log);async function computeSriHash(source) { const bytes = new TextEncoder().encode(source); const digest = await crypto.subtle.digest("SHA-384", bytes); const base64 = btoa(String.fromCharCode(...new Uint8Array(digest))); return `sha384-${base64}`;} const file = 'console.log("CompleteFrontend");\n';computeSriHash(file).then(console.log);sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4eComputing...allowWaiting for Web Crypto.
Waiting for Web Crypto.
crypto.subtle.digest('SHA-384', bytes). The comparison simulates what the browser does after fetching a script with an SRI attribute.The crossorigin="anonymous" attribute lets the browser perform the CORS-enabled fetch needed for many cross-origin integrity checks without sending credentials. If the CDN changes a byte, the hash changes and the browser fails closed. That is why SRI does not fit resources that intentionally change behind the same URL, such as a live latest.js endpoint or the old Polyfill.io style of generating different code per request.
If you copy the hash after a file is already malicious, SRI faithfully pins the malicious file. Pair it with trusted sources, reviewed versions, self-hosting when sensible, and the Web Crypto and text encoding ideas behind the hash.
Typosquatting and dependency confusion
NAME CHECKTyposquatting registers a name that looks like the package you meant: loadsh instead of lodash, a swapped letter, a missing scope, or a lookalike word. Dependency confusion is different: internal package names accidentally resolve from a public registry, often because private scopes or registry rules are not configured clearly.
const popularPackages = ["lodash", "react", "express", "next", "axios"];function normalizePackageName(name) { return name.toLowerCase().replace(/^@[^/]+[/]/, "").replace(/[^a-z0-9]/g, "");}function levenshteinDistance(left, right) { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { const current = [row]; for (let column = 1; column <= right.length; column += 1) { const cost = left[row - 1] === right[column - 1] ? 0 : 1; current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost); } previous.splice(0, previous.length, ...current); } return previous[right.length];}function checkSuspiciousPackageName(candidate) { const normalized = normalizePackageName(candidate); const closest = popularPackages .map((packageName) => ({ packageName, distance: levenshteinDistance(normalized, normalizePackageName(packageName)) })) .sort((left, right) => left.distance - right.distance)[0]; const suspicious = closest.distance > 0 && closest.distance <= 2; return { candidate, closest: closest.packageName, distance: closest.distance, suspicious };}console.log(JSON.stringify(checkSuspiciousPackageName("loadsh")));loadshlodash2suspiciousCompared with 15 names in the lesson watchlist.
Pause: loadsh is only 2 edits from lodash. Verify the package page before installing.
Step through a real suspicious-name check. It is a guardrail, not a verdict; humans still verify intent and ownership.
script
function normalizePackageName(name) { return name.toLowerCase().replace(/^@[^/]+[/]/, "").replace(/[^a-z0-9]/g, "");}function levenshteinDistance(left, right) { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { const current = [row]; for (let column = 1; column <= right.length; column += 1) { const cost = left[row - 1] === right[column - 1] ? 0 : 1; current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost); } previous.splice(0, previous.length, ...current); } return previous[right.length];}function checkSuspiciousPackageName(candidate) { const normalized = normalizePackageName(candidate); const closest = popularPackages .map((packageName) => ({ packageName, distance: levenshteinDistance(normalized, normalizePackageName(packageName)) })) .sort((left, right) => left.distance - right.distance)[0]; const suspicious = closest.distance > 0 && closest.distance <= 2; return { candidate, closest: closest.packageName, distance: closest.distance, suspicious };}console.log(JSON.stringify(checkSuspiciousPackageName("loadsh")));Scoped packages reduce ambiguity when the organization controls the scope and every tool uses the same registry mapping. Put private packages behind a private scope and configure npm so @acme/* goes to the private registry instead of the public default.
@acme:registry=https://npm.acme.internal///npm.acme.internal/:_authToken=${NPM_TOKEN}always-auth=trueSlopsquatting is the AI-era cousin of typosquatting: attackers register package names that coding assistants are likely to hallucinate. Treat generated install commands as suggestions, not facts. Search the registry, read the package page, verify maintainers and source, and ask whether the package should exist at all.
Fewer, better dependencies
REVIEWThe most reliable dependency is the one you did not need. Modern JavaScript and browsers already provide many APIs that older projects used packages for: fetch, URL, URLSearchParams, Intl, structuredClone, Array.isArray, crypto.subtle, and TextEncoder cover many small helpers.
const original = { user: { name: "Ada" }, tags: ["security"] };const copy = structuredClone(original);copy.tags.push("reviewed"); console.log(original.tags.join(", "));console.log(new URL("/javascript/npm", "https://completefrontend.com").href);console.log(new Intl.ListFormat("en").format(["fetch", "URL", "structuredClone"]));| Question | Check | Professional habit |
|---|---|---|
| Need | Can the platform do it? | Prefer fetch, URL, Intl, structuredClone, crypto.subtle, and TextEncoder when they solve the job. |
| Maintenance | Recent releases, issue responses, tests, and bus factor. | A tiny abandoned package can be riskier than writing five clear lines yourself. |
| Install behavior | preinstall, install, postinstall, native builds, and binary downloads. | Inspect scripts before first install; use --ignore-scripts for investigation when appropriate. |
| Package shape | Bundle size, ESM/CJS exports, transitive count, and tree-shaking. | A dependency that pulls a large graph for one helper is expensive to defend. |
| Governance | License, maintainers, provenance, 2FA/trusted publishing, and release notes. | A healthy project makes releases explainable and reversible. |
const packageChecklist = { name: "left-pad-alternative", needed: false, maintenance: "last release 3 years ago", installScripts: ["postinstall"], license: "MIT",}; console.log(packageChecklist.needed ? "review deeply" : "prefer platform code");console.log(packageChecklist.installScripts.includes("postinstall") ? "inspect scripts" : "no install script");Vendoring means copying a small, reviewed piece of code into your repository. It can be a good choice for stable, tiny helpers when you want no install-time behavior and no transitive graph. It also makes you responsible for fixes, license compliance, and updates. Depending is better when the library is complex, security-sensitive, actively maintained, and hard to get right yourself.
Tooling and workflow
Tools help when they are used as evidence rather than as autopilot. Dependabot and Renovate open dependency update pull requests. Socket and Snyk add package-risk, malware, reachability, and vulnerability signals. npm provenance and signatures add publish-time evidence when a package and registry support them.
npm publish --provenancenpm audit signatures| Control | Good at | Not good at |
|---|---|---|
| npm audit | Known published advisories in the resolved dependency graph. | New malware, ownership risk, typosquats, bad maintainer practices, and advisories that do not exist yet. |
| Lockfile + npm ci | Reproduces the same package graph and integrity hashes in CI. | Whether the locked graph is trustworthy, maintained, or free of malicious lifecycle scripts. |
| Subresource Integrity | Browser refuses a fetched CDN file whose bytes do not match the hash. | Resources that change dynamically, packages installed by npm, or compromised code whose hash you already trusted. |
| Provenance/signatures | Links a published package to a signed build or verifies registry signatures when available. | The code's quality, whether the maintainer meant to publish, or packages without supported attestations. |
- Start with need: remove the dependency if a platform API or a few maintained lines solve it.
- Verify the name, scope, owner, repository, license, release history, and install scripts before first install.
- Install with a committed lockfile, review the diff, and use
npm ciin CI. - Run audit and risk tooling, then apply updates through pull requests with tests.
- Use SRI or self-hosting for browser-loaded third-party assets, and prefer provenance-aware publishing for your own packages.
Common misconceptions
- “npm audit clean means safe.” It means no known advisory matched the resolved graph. It does not prove maintainers, names, scripts, or new releases are safe.
- “A lockfile prevents attacks.” A lockfile prevents accidental drift. It can also preserve a bad version until you update it intentionally.
- “SRI solves all CDN risk.” SRI protects exact bytes for immutable resources. It does not help dynamic resources or a hash copied from already-compromised code.
- “Popular packages are automatically trustworthy.” Popular packages are attractive targets. Popularity is one signal, not a security model.
- “Install scripts are always malicious.” Many are legitimate, especially for native modules. The point is to know when you are running third-party code.
- “AI-generated install commands are facts.” Slopsquatting exists because plausible package names can be hallucinated and then registered by attackers.
| If you hear | Ask | Better habit |
|---|---|---|
| Just run audit fix | Will it cross semver boundaries or hide a migration? | Review the diff and run tests, especially with --force. |
| Use a CDN script | Is the URL immutable, owned by someone trustworthy, and protected by SRI? | Self-host or pin a versioned URL with a verified hash. |
| Install this package from an AI answer | Does the package exist, who owns it, and why do we need it? | Verify names manually and prefer platform APIs when possible. |
Practice exercises
5 EXERCISESType the exact output of the repository count snippet.
const lockfilePackageEntries = 611;
const directDependencyNames = 35;
console.log(lockfilePackageEntries + " vs " + directDependencyNames);The real count is 611 vs 35. That is why transitive dependencies dominate review work.
Type the SRI string for the sample file.
console.log("sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e");The printed SRI string belongs only to the exact sample bytes from the lesson.
Type the exact output of the name-check snippet.
const result = { candidate: "loadsh", closest: "lodash", distance: 2, suspicious: true };
console.log(result.closest + " / " + result.distance + " / " + result.suspicious);The checker prints lodash / 2 / true, so you should verify before installing loadsh.
Predict the phrase printed by the audit-limitation snippet.
const auditKnows = ["published advisories", "severity", "patched versions"];
console.log(auditKnows.includes("unreported malware") ? "complete" : "advisory database only");The snippet prints advisory database only. Audit is useful, but it is not a complete malware detector.
Which platform API should replace the tiny package?
const tinyPackage = { name: "is-array", native: "Array.isArray" };
console.log(tinyPackage.native);Array.isArray(value)Use the built-in Array.isArray instead of adding a dependency for this tiny helper.
Check your understanding
8 QUESTIONSQuestion 1 of 8What is the software supply chain for a frontend app?
Choose an answer to see the explanation.
Question 2 of 8What does the real repository count print?
Read the code, then predictconst lockfilePackageEntries = 611; const directDependencyNames = 35; console.log(lockfilePackageEntries + " vs " + directDependencyNames);Choose an answer to see the explanation.
Question 3 of 8Which statement about
npm auditis accurate?Choose an answer to see the explanation.
Question 4 of 8What does the SRI helper print for the sample file?
Read the code, then predictconsole.log("sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e");Choose an answer to see the explanation.
Question 5 of 8Why does the checker flag
loadsh?Read the code, then predictconst popularPackages = ["lodash", "react", "express", "next", "axios"]; function normalizePackageName(name) { return name.toLowerCase().replace(/^@[^/]+[/]/, "").replace(/[^a-z0-9]/g, ""); } function levenshteinDistance(left, right) { const previous = Array.from({ length: right.length + 1 }, (_, index) => index); for (let row = 1; row <= left.length; row += 1) { const current = [row]; for (let column = 1; column <= right.length; column += 1) { const cost = left[row - 1] === right[column - 1] ? 0 : 1; current[column] = Math.min(current[column - 1] + 1, previous[column] + 1, previous[column - 1] + cost); } previous.splice(0, previous.length, ...current); } return previous[right.length]; } function checkSuspiciousPackageName(candidate) { const normalized = normalizePackageName(candidate); const closest = popularPackages .map((packageName) => ({ packageName, distance: levenshteinDistance(normalized, normalizePackageName(packageName)) })) .sort((left, right) => left.distance - right.distance)[0]; const suspicious = closest.distance > 0 && closest.distance <= 2; return { candidate, closest: closest.packageName, distance: closest.distance, suspicious }; } console.log(JSON.stringify(checkSuspiciousPackageName("loadsh")));Choose an answer to see the explanation.
Question 6 of 8Which control is best for a CDN script that must never change silently?
Choose an answer to see the explanation.
Question 7 of 8What does
npm install --ignore-scriptshelp with?Choose an answer to see the explanation.
Question 8 of 8Which incident best illustrates a long social-engineering path outside npm?
Choose an answer to see the explanation.
Key takeaways
- The supply chain includes dependencies, transitive dependencies, build tools, registries, CDNs, CI, tokens, and publish workflows.
npm auditreports known advisories; lockfiles andnpm cimake installs reproducible, not automatically safe.- SRI hashes exact CDN bytes with Web Crypto-style algorithms and fails closed on mismatches, but only for immutable resources.
- Typosquatting, dependency confusion, and slopsquatting are name-resolution problems. Verify names, scopes, and registry configuration.
- Fewer, better dependencies are easier to audit. Prefer platform APIs, review install scripts, and use update tooling as evidence.
One-line summary: treat dependency installation and third-party scripts as code review events, then use hashes, lockfiles, provenance, and smaller graphs to make trust verifiable.
Up next: the concurrent isolation and sandboxing lesson shows how to run untrusted code or content with stronger boundaries when verification is not enough.