completefrontend
Open lab My progress
THE JAVASCRIPT FIELD GUIDE

Supply-chain security

Learn how to verify JavaScript dependencies with audit reports, lockfiles, SRI, provenance, name checks, and smaller dependency choices.

By the end, you can
  • 01
    List the parts of the chainName the packages, tools, registries, CDNs, and CI systems that can change the code your users run.
  • 02
    Check installs and CDN filesKnow what npm audit, lockfiles, npm ci, install-script review, provenance, and SRI each protect against, and what they miss.
  • 03
    Use fewer, safer packagesSpot lookalike package names, keep private names on your private registry, and use built-in tools instead of small packages.

Check before you trust

Most JavaScript projects run code that other people wrote. That code comes as packages. A package is a folder of code that someone published so that others can reuse it. A package your project needs is called a dependency.

Supply-chain security means checking outside code before it runs. Outside code can run in four places:

  • on your own laptop;
  • in CI, the server that tests and builds your project after every change;
  • inside a build tool;
  • in your user's browser.

JavaScript makes reusing code easy. This lesson shows how to reuse it without trusting it blindly.

Plain definition

A software supply chain is everything that can change the code your users finally run:

  • the packages you install, and the packages they install;
  • the tools that build and test your code;
  • the registry, the online store that packages come from;
  • CDNs, servers that send ready-made files, such as scripts, straight to browsers;
  • the secret keys, called tokens, that let someone publish;
  • the steps that publish a new version.

The rule of this lesson: check the code you install before you trust it.

This lesson builds on npm & package.json. That lesson explains version ranges, lockfiles, and install scripts. This lesson also links back to transpilers and polyfills. In 2024 a polyfill CDN, Polyfill.io, started sending harmful code to some visitors. You will also see why bundlers, Web Crypto, text encoding, and XSS defenses matter here.

Real-life analogyA food shop and sealed packs

A food shop does not grow its own food. It picks suppliers it trusts. It checks that every pack is sealed. It writes down each batch number. When a recall notice comes, it takes that batch off the shelves. It stops buying from a supplier when the owner changes or the food is unsafe.

JavaScript teams do the same with package names, lockfiles, audits, hashes, and release steps.

In real life: Farm, truck, warehouse, and shop
In JavaScript: Package authors, registries, CDNs, CI, and npm
In real life: Ingredient label
In JavaScript: package.json and release notes
In real life: Sealed pack with a batch number
In JavaScript: Lockfile hashes and SRI hashes
In real life: Recall notice
In JavaScript: Security warnings from npm audit, Dependabot, Renovate, Snyk, or Socket

What the supply chain includes

REAL COUNTS

The chain is bigger than the packages you install yourself. It includes:

  • direct dependencies, the packages you list in package.json;
  • transitive dependencies, the packages that your packages need;
  • build tools, registries, and the CI machines that run them;
  • tokens, lockfiles, CDNs, and scripts the browser loads;
  • the people and bots that are allowed to publish any of these.

The table lists each part. Read across: what the part is, then how you check it.

The parts of a frontend project's supply chain
Part of the chainWhat it isHow you check it
Direct dependenciesThe packages you list in your package.json files.Check each name, owner, past releases, license, and install scripts, and whether you need it at all.
Transitive dependenciesThe packages that your packages need, and the packages those need.Read lockfile changes. This repository's root lockfile has 832 package entries, but its workspaces name only 56 direct dependencies.
Build toolsBundlers, compilers, minifiers, test tools, and release scripts.Treat them like any package. They run on your laptop and in CI, before users ever see your app.
CDNsScripts, styles, polyfills, fonts, and analytics that the browser loads from another site.Use SRI for files that never change. Avoid services that can send different bytes each time.
CI and registriesTokens, publish workflows, package registries, file stores, and GitHub Actions.Give tokens the fewest permissions, use trusted publishing, scan for leaked secrets, and keep release steps easy to review.

Most of the chain is transitive dependencies. Each package you add can bring its own packages, and those bring more. This repository shows how big the gap is. A script read the root package-lock.json, the lockfile npm writes. It found:

  • 832 package entries, not counting the project itself;
  • only 56 different direct dependency names. They come from the 3 projects in the repository, called workspaces, each with its own package.json.
Left: a cart called manifests holds a card that says 56 names, the packages you chose. An arrow labelled pulls in leads to a cart called lockfile with a card that says 832 entries, everything npm installs.you chose56 namesmanifestspulls innpm installs832 entrieslockfile
The workspace package.json files name 56 dependencies. The root lockfile installs 832 packages, because each one brings packages of its own.

That gap is why lockfiles, review tools, and fewer dependencies matter.

Count the packages in the lockfileJavaScript
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");

This is a Node.js script, so it does not run in the browser. Here is what its lines do:

  • Line 2 reads the lockfile and turns its JSON text into an object.
  • Line 4 counts every package entry except the root one, whose path is "".
  • Lines 5 to 7 keep the workspace entries: the ones outside node_modules/ that have a name.
  • Lines 8 to 13 put every dependency name from those workspaces into a Set. A Set keeps each name only once.
  • Lines 14 and 15 print both counts.
Why look past your direct dependencies?

Direct dependencies are the names your team chose. Transitive dependencies are code you still run, because your choices pulled them in. Attackers often aim at packages deep in the tree. Such a package can be used by millions of projects, yet almost nobody on those teams reads its code.

Real attacks and what they teach

TIMELINE

Each event below really happened. Each one shows a different way the chain can fail:

  • a package disappears, and builds break;
  • a new maintainer, the person who looks after a package, adds harmful code;
  • an attacker steals a maintainer's account;
  • a maintainer breaks their own package on purpose, as a protest;
  • an attacker spends years winning a project's trust;
  • a CDN gets a new owner;
  • phishing, a fake email or page, steals a login;
  • a package copies itself to other packages while it installs, like a worm.

Each row is one event. Read when it happened, then what it teaches.

Real supply-chain attacks and mistakes to remember
What was hitWhenWhat it teaches
left-padMarch 22, 2016After a naming dispute, a maintainer removed his small npm packages. Builds around the world broke, and npm made removing packages harder.
event-stream2018A new maintainer added a dependency, flatmap-stream, with harmful code aimed at users of the Copay wallet. It was found in November 2018.
ua-parser-jsOctober 22, 2021An attacker stole the maintainer's account and published versions that installed coin miners and password stealers.
colors/fakerJanuary 2022The maintainer broke his own popular packages on purpose: colors got an endless loop, and faker got broken releases.
xz-utilsMarch 2024Not npm, but worth knowing. After years of winning trust, an attacker hid a backdoor for SSH logins in xz 5.6.0 and 5.6.1 release files. Andres Freund found it.
polyfill.ioFebruary-June 2024The CDN got a new owner. Scripts from cdn.polyfill.io were then reported to send harmful JavaScript to some visitors of sites that used it.
npm phishing and Shai-HuludSeptember 2025Fake emails stole maintainer logins. Then an npm worm used install scripts to steal secrets and publish harmful versions of more packages, including popular ones such as @ctrl/tinycolor. The package counts changed as reports were updated.

xz-utils is not an npm package. It is a compression library that operating systems use. It still matters to JavaScript developers. Build machines depend on operating-system libraries, CI images, and maintainers you may never meet.

The Shai-Hulud reports matter for another reason. Installing a package can run its code before your app even starts. If that code steals npm, GitHub, or cloud passwords, the attacker can publish more harmful packages.

Test your setup against each attack

For each event, ask how your setup would respond:

  • Would the lockfile keep the bad version, or stop a surprise new one?
  • Would npm ci install exactly the same packages again?
  • Would --ignore-scripts let you look at the package safely?
  • Would a secret scanner notice a leaked token?
  • Would a CDN hash make the browser refuse a changed file?

Different attacks need different defenses.

If a maintainer's account is stolen

A package you know can still turn harmful when someone takes over the account that publishes it. Three habits make that less likely and limit the harm:

  • sign-in with a second step that phishing cannot trick, such as a hardware security key;
  • trusted publishing: CI proves who it is with a short-lived OIDC token, a login that expires within minutes, instead of a saved npm token;
  • a short list of people who are allowed to publish.

If a new release looks harmful:

  1. Pause deployments.
  2. Find the version you installed, in lockfiles and CI logs.
  3. Cancel every npm, GitHub, and cloud token that may have leaked, and make new ones.
  4. Rebuild from source code you have reviewed.

Do not expect npm ci to fix a bad lockfile. It installs exactly what the lockfile says, the bad version included.

npm audit, lockfiles, and install scripts

COMMANDS

npm audit checks your installed packages against a list of advisories. An advisory is a public warning that some versions of a package have a security problem. For each match, the report shows:

  • how serious the problem is, called its severity;
  • which of your packages pulls in the weak one;
  • which version fixes it;
  • sometimes, a command that applies the fix.

It has two limits:

  • It knows nothing about a brand-new attack until someone publishes an advisory.
  • It can warn about code your app never runs, or code that only development tools use.
Five npm commands for checking and installingbash
npm audit --audit-level=highnpm audit fixnpm audit fix --forcenpm cinpm install --ignore-scripts

These are terminal commands, not browser code. Line by line:

  1. npm audit --audit-level=high lists advisories. It fails only when one is high or critical.
  2. npm audit fix installs fixed versions that stay inside the version ranges you allow.
  3. npm audit fix --force also allows bigger jumps, such as a new major version. Treat it like a move to a new version: review the changes and run your tests.
  4. npm ci installs exactly what the lockfile says. It is meant for CI.
  5. npm install --ignore-scripts installs without running the packages' install scripts.

npm ci treats the lockfile as the truth:

  • It stops with an error if package.json and package-lock.json disagree.
  • It deletes the old node_modules folder first.
  • It never changes package.json or package-lock.json.

If a setting such as --legacy-peer-deps was used to make the lockfile, CI must use the same setting. Teams usually save it in the project's .npmrc file, npm's settings file.

A lockfile does not remove risk. It records the exact version and an integrity hash of every package, so the same packages install again next time. An integrity hash is a short fingerprint computed from a file's bytes. If any byte changes, the fingerprint changes.

Teams pick one of two ways to set versions:

  • Pin every version exactly. You get fewer surprises, but updates take more work.
  • Allow a range in package.json, such as ^5.1.0, and commit the lockfile. The ^ means "any 5.x version from 5.1.0 up". This is the usual choice for apps. You update on purpose, CI repeats the lockfile exactly, and update tools open a pull request for each change.
An allowed range and the one version the lockfile pickedPop out in the code editor (opens in a new tab)JavaScript
const manifestRange = "^5.1.0";
const lockedVersion = "5.3.2";

console.log(manifestRange + " -> " + lockedVersion);

Line 1 is the range that package.json allows. Line 2 is the one exact version that the lockfile recorded. Line 4 prints ^5.1.0 -> 5.3.2.

A tag that says ^5.1.0, the range package.json allows. An arrow labelled npm picks leads to a box called lockfile holding a card that says 5.3.2, the one exact version.^5.1.0allowed rangenpm picks5.3.2lockfileone exact version
package.json allows any version from the range ^5.1.0. The lockfile writes down the one version npm picked, 5.3.2, and npm ci installs that one every time.

The arrow in that output is only text. It is not a real version check. npm checks the real lockfile, and npm ci installs the recorded version without picking a newer one.

An integrity hash catches bytes that differ from the file npm saw when it wrote the lockfile. It does not tell you who wrote those bytes. It also does not tell you whether the package was safe at that moment. So review lockfile changes the way you review code changes. Look closely when a package name, version, download address, or integrity value changes.

npm audit only knows published advisories. It does not scan for malware, and it does not check whether your app really uses the weak code. A clean report means no published advisory matched. It cannot clear a stolen account or a new harmful release. The report also sends your package names to your registry, private names included. Check your company's rules before you run it in CI.

Risk signal or safeguard?
  • A new package adds a postinstall script
  • CI uses npm ci from a committed lockfile
  • The package has many weekly downloads
  • A CDN script with a fixed version has a checked sha384-... integrity value
  • The package name is loadsh when you meant lodash
  • npm audit reports zero vulnerabilities today
Try it yourself
0 of 6 correct

Sort each card by what it tells you when you review a package. A risk signal means stop and look closer. A safeguard is a defense. Some cards help, but are not enough on their own.

Choose a category for every card. You can change an answer at any time; Reset clears them all.
Read install scripts before they run

A lifecycle script is a command that a package lists in its package.json. npm runs it at a set moment. The install scripts, preinstall, install, and postinstall, run while the package installs, with your permissions.

npm install --ignore-scripts lets you install a new package to look at it, without running those scripts. It has limits:

  • It only skips install scripts. Code that your app imports later still runs.
  • Some packages, such as ones that must compile code for your machine, do not work without their install step.
  • It is a way to look, not a safe sandbox. It is not a lasting fix for a package you do not trust.
Find install scripts without running themPop out in the code editor (opens in a new tab)JavaScript
const scripts = { postinstall: "node ./build.js" };
const hookNames = ["preinstall", "install", "postinstall"];
const installHooks = Object.keys(scripts).filter((name) => hookNames.includes(name));

console.log(installHooks.join(", ") || "no install hooks");

This small example prints postinstall. It only reads the script names. It never runs build.js.

  • Line 1 is a scripts object, like the one in a package.json. It has one postinstall script.
  • Line 2 lists the three script names that npm runs during install.
  • Line 3 keeps only the names in scripts that are install scripts.
  • Line 5 prints those names, or no install hooks if there are none.

In a real review, also read what the script does, and the files it runs. Then decide whether a normal install is fine.

Subresource Integrity

HASH IT

Some pages load scripts from a CDN. If the CDN's file changes, your page runs the new code without asking you. Subresource Integrity, or SRI, stops that. In the HTML, you write the hash the file should have. The browser then:

  1. downloads the file;
  2. computes the hash of the bytes it got;
  3. compares that hash with the integrity attribute;
  4. runs the file only if they match. If they differ, it refuses the file.

The hash method here is SHA-384. It turns any bytes into a 48-byte fingerprint. SRI fits files that should never change, such as a CDN file with its version number in its address.

A CDN script with its expected hashHTML
<script  src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.8/dist/js/bootstrap.bundle.min.js"  integrity="sha384-FKyoEForCGlyvwx9Hj09JcYn3nv7wiPVlz7YYwJrWVcXK/BmnVDxM+D2scQbITxI"  crossorigin="anonymous"></script>
  • Line 2 is the file's address. The exact version, 5.3.8, is part of it.
  • Line 3 is the expected hash: sha384-, then the fingerprint written in base64. Base64 is a way to write bytes as letters, digits, +, and /.
  • Line 4, crossorigin="anonymous", lets the browser check a file from another site. It is explained below.
The page expects integrity sha384-Tf2F and so on. Top row: the same file bytes hash to sha384-Tf2F, which matches, so the file runs. Bottom row: a file with one byte changed hashes to a new hash, which does not match, so the browser refuses it.integrity: sha384-Tf2F…file bytesSHA-384sha384-Tf2F…match: runs1 byte changedSHA-384a new hashno match: refused
The browser hashes the bytes it downloaded with SHA-384 and compares the result with the integrity value. Change one byte and the hash changes, so the file is refused.
Step through making an SRI hash
Step 0 of 6Ready
Your turn: follow the blue line

Step through how an SRI value is made: bytes, a SHA-384 hash, base64, and sha384- in front.

Running in
  1. script
Next: line 8
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);
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.
Change the bytes, and the SRI hash changes
SRI helper using Web CryptoPop out in the code editor (opens in a new tab)JavaScript
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);
Resultoriginal bytes
Original SRIsha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e
SRI of these bytesComputing...
What the browser doesrun

Waiting for Web Crypto.

Try it yourself

Waiting for Web Crypto.

Your browser computes the hash with crypto.subtle.digest('SHA-384', bytes). Then it compares the result with the original value, the way a browser checks a script that has an SRI attribute.

crossorigin="anonymous" asks the browser to download the file with CORS and without cookies. CORS is the browser's rule for reading files from another site. The browser needs it to check many files that come from other sites.

If the CDN changes even one byte, the hash changes, and the browser refuses the file. So SRI does not fit files that change at the same address. Two examples:

  • a latest.js file that always holds the newest version;
  • a service like the old Polyfill.io, which built different code for each visitor.
SRI checks the bytes, not whether they are safe

If you copy the hash after the file is already harmful, SRI locks in the harmful file. Use SRI together with trusted sources and versions you have reviewed. Host the file yourself when that makes sense. The Web Crypto and text encoding lessons explain the hashing and the bytes behind it.

Typosquatting and dependency confusion

NAME CHECK

Typosquatting means publishing a package under a name that looks like a popular one. The attacker hopes people will mistype the name. The fake name can be:

  • a typo, such as loadsh instead of lodash;
  • the right name without its scope;
  • a word that looks almost the same.

A scope is the @name/ part at the start of names like @acme/tools. It groups packages under one owner.

Dependency confusion is a different attack. Say your company has a private package for its own use. If npm is not told where that name lives, it may fetch a package with the same name from the public registry instead. An attacker may have published one there on purpose.

Each row below is one attack. Read what goes wrong, then the first defense to use.

Three attacks on package names and publishers
AttackWhat goes wrongFirst defense
TyposquattingYou type a name like loadsh, and npm installs an attacker's package.Before you install, check the exact name, the owner, the source code link, and past releases.
Dependency confusionYour private package name is fetched from the public registry, because no setting says where it lives.Put private packages under a scope your company owns. Send that scope to your private registry, on laptops and in CI.
Stolen maintainer accountAn attacker publishes a new release under the real package name.Protect publisher accounts, keep the list of publishers short, and check the proof that comes with each release.

The playground below runs a small name checker. It compares the name you type with a list of popular names. It counts edits: how many single letters you must add, remove, or change to turn one name into the other. This count is called the Levenshtein distance.

A tag that says loadsh, the name you typed, and a tag that says lodash, the popular name. Between them, an arrow labelled 2 edits, because the a and d are swapped. Underneath: suspicious: true.loadshyou typed2 editsa and d swappedlodashpopular namesuspicious: true
loadsh is only 2 edits away from lodash: the a and the d swap places. That is close enough for the checker to say suspicious: true.
Check a package name before you install
Package name checkerPop out in the code editor (opens in a new tab)JavaScript
const popularPackages = ["lodash", "react", "express", "next", "axios"];function normalizePackageName(name) {  return name.toLowerCase().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")));
Check resultalmost a popular name
Cleaned-up nameloadsh
Closest popular namelodash
Edits away2
Decisionsuspicious

Compared with the 15 popular names on the lesson's list.

Try it yourself

Stop and check: loadsh is only 2 edits from lodash. Look at the package page before you install it.

This checker is for learning. Real scanners also look at spelling, maintainer history, when each version was published, install scripts, and registry data.
Replay the check of loadsh
Step 0 of 5Ready
Your turn: follow the blue line

Step through a real name check. It only tells you to look closer; a person still checks who owns the package and why.

Running in
  1. script
Next: line 1
function normalizePackageName(name) {  return name.toLowerCase().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")));
CallStoreChangeResultBlue line: runs next. Ran: already executed.
Recent returnsNothing yet. Start with the blue line.
A guided replay recorded from real JavaScript calls, not an engine debugger. Step follows executed statements; Back reviews a snapshot. Reset starts a fresh run.

A scope helps when your company owns it and every tool uses the same registry setting. Put private packages under your private scope. Then tell npm to fetch @acme/* from your private registry, not from the public one.

Send @acme packages to the private registry (.npmrc)bash
@acme:registry=https://npm.acme.internal///npm.acme.internal/:_authToken=${NPM_TOKEN}

This is npm's settings file, not a shell script.

  • Line 1 sends every @acme package to the private registry.
  • Line 2 gives the login token for that registry only. The token comes from ${NPM_TOKEN}, an environment variable: a value set outside the code.

Never write the token itself in a file you commit. Check this setting in CI as well as on your laptop.

Slopsquatting is a newer kind of typosquatting. AI coding assistants sometimes make up package names that sound right but do not exist. Attackers then publish packages under those names. So treat an install command from an AI as a suggestion, not a fact. Before you run it:

  • search the registry for the name;
  • read the package page;
  • check who maintains it and where its source code lives;
  • ask whether you need a package at all.

Fewer, better dependencies

REVIEW

The safest dependency is the one you do not need. Browsers and JavaScript now include many tools that older projects installed as packages:

  • fetch sends network requests;
  • URL and URLSearchParams build and read web addresses;
  • Intl formats dates, numbers, and lists for many languages;
  • structuredClone makes a deep copy of an object;
  • Array.isArray tests whether a value is an array;
  • crypto.subtle computes hashes;
  • TextEncoder turns text into bytes.
Built-in tools instead of small packagesPop out in the code editor (opens in a new tab)JavaScript
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"]));
  • Line 2 makes a deep copy, so line 3 adds "reviewed" to the copy only.
  • Line 5 prints security, because the original did not change.
  • Line 6 builds a full address: https://completefrontend.com/javascript/npm.
  • Line 7 prints fetch, URL, and structuredClone.

Ask the questions in this table before you add a package. Read across: the question, what to look at, and the habit that helps.

Questions to ask before you add a package
QuestionWhat to look atHabit that helps
Do you need it?Can a built-in tool do the job?Use fetch, URL, Intl, structuredClone, crypto.subtle, or TextEncoder when they do the job.
Is it looked after?Recent releases, answered issues, tests, and how many people can maintain it.A tiny package nobody looks after can be riskier than five clear lines of your own code.
What runs at install?preinstall, install, and postinstall scripts, code compiled on install, and downloaded program files.Read the scripts before the first install. Use --ignore-scripts when you only want to look.
How big is it?Its size, how it exports code, and how many packages it brings along.A package that brings many others for one small helper is a lot to check.
Who publishes it?License, maintainers, provenance, sign-in protection, and release notes.A healthy project makes each release easy to explain and easy to undo.
Can you trust its CI actions?Who owns each action, its code at the pinned commit, token permissions, and how pins get updated.Pin full commit SHAs, give the fewest permissions, and review every update.
Write your review down as an objectPop out in the code editor (opens in a new tab)JavaScript
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");
  • Lines 2 to 6 record what you found about the package.
  • Line 9 prints prefer platform code, because needed is false.
  • Line 10 prints inspect scripts, because the package has a postinstall script.

Vendoring means copying a small piece of code you have reviewed into your own repository, instead of installing it as a package. It suits small helpers that rarely change. Nothing runs at install time, and no extra packages come along. But the code is now yours: fixes, license rules, and updates are your job.

Install a package instead when the code is large, security-related, well looked after, and hard to get right yourself.

Tools and a review routine

Tools help most when you read what they find, instead of letting them decide for you:

  • Dependabot and Renovate open a pull request for each dependency update.
  • Socket and Snyk flag risky packages, known malware, and known security problems. Some also check whether your code reaches the weak part.
  • npm provenance and registry signatures add proof about how a package was published, when the package and registry support them.
Publish with provenance, then check signaturesbash
npm publish --provenancenpm audit signatures

These are terminal commands.

  • A registry signature is a mark the registry adds to a package. npm uses it to check that the package's name, version, and hash are what the registry published.
  • Provenance is a public record that links a published package to its source repository and to the build that made it.

Line 1 publishes a package with provenance. Line 2, npm audit signatures, checks the signatures and provenance records of your installed packages, where they exist. Neither one proves that the source code is harmless.

A publish workflow with pinned actionsyaml
name: Publish packageon:  release:    types: [published]permissions:  contents: readjobs:  publish:    runs-on: ubuntu-latest    permissions:      contents: read      id-token: write    steps:      - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1      - uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0        with:          node-version: 22          registry-url: https://registry.npmjs.org      - run: npm ci      - run: npm publish --provenance --access public

This is a GitHub Actions workflow file, not code you can run here. A GitHub Action is a reusable step that someone else wrote, so it is part of your supply chain too. Line by line:

  • Lines 10 to 12 let the job only read the repository, plus id-token: write. That lets it get a short-lived OIDC token for trusted publishing, instead of a saved npm token.
  • Lines 14 and 15 pin each action to a full commit SHA: the long ID of one exact version of its code. A tag such as v7 can be moved to other code; a SHA cannot. The comment shows the matching version.
  • Line 19 installs exactly what the lockfile says.
  • Line 20 publishes the package with provenance.

Read each action's code at that commit. Update the pins through reviewed pull requests, for example ones that Dependabot opens. A pin keeps one version fixed. It does not make that version safe, and you still need to update it.

Each defense in this table catches some problems and misses others. Read across: what it catches, then what it does not catch.

What each defense does and does not catch
DefenseWhat it catchesWhat it does not catch
npm auditPublished advisories that match your installed packages.New malware, stolen accounts, lookalike names, careless maintainers, and problems nobody has reported yet.
Lockfile + npm ciInstalls the same packages, with the same hashes, every time.Whether those packages are safe, looked after, or free of harmful install scripts.
Subresource IntegrityMakes the browser refuse a CDN file whose bytes do not match the hash.Files that change on purpose, packages installed by npm, and a file that was already harmful when you copied its hash.
Registry signaturesChecks that a package's name, version, and hash are what a supporting registry published.Who built the code, whether its source is safe, and packages that have no signature.
ProvenanceLinks a published package to its source repository and the build that made it.Whether the source is harmless, whether the publisher's account was stolen, and builds that do not support it.
Full-SHA action pinKeeps a workflow on one exact version of an action until you change the pin.Whether that version is safe or up to date. Review and update pins on purpose.

Put together, a review routine looks like this:

  1. Start with the need. Skip the package if a built-in tool or a few lines of your own code do the job.
  2. Before the first install, check the name, scope, owner, source code, license, past releases, and install scripts.
  3. Install with a committed lockfile, read the lockfile changes, and use npm ci in CI.
  4. Run npm audit and other risk tools. Apply updates through pull requests, with tests.
  5. Pin GitHub Actions to full SHAs you have reviewed. Give workflows the fewest permissions they need. Update pins through reviewed pull requests.
  6. Use SRI, or host the file yourself, for scripts the browser loads from other sites. Publish your own packages with provenance.

Common misconceptions

  • "A clean npm audit means safe." It means no published advisory matched your packages. It says nothing about owners, names, install scripts, or new releases.
  • "A lockfile stops attacks." A lockfile stops surprise version changes. It can also keep a bad version until you update on purpose.
  • "SRI solves every CDN risk." SRI protects the exact bytes of a file that never changes. It does not help with files that change, or with a hash copied from a file that was already harmful.
  • "Popular packages are safe." Popular packages are attractive to attackers. Popularity is one clue, not proof.
  • "Install scripts are always harmful." Many are normal, especially for packages that compile code. The point is to know when you are running someone else's code.
  • "An install command from an AI is a fact." AI assistants can make up package names that sound right. Attackers publish packages under those names.

Each row is advice you may hear. Read the question to ask, then a better habit.

Questions to ask about common advice
When you hearAskBetter habit
"Just run audit fix."Will it jump to a new major version and need code changes?Read the changes and run the tests, especially with --force.
"Load it from a CDN."Does the address name an exact version? Is the owner trustworthy? Is there an SRI hash?Host the file yourself, or use an address with an exact version and a checked hash.
"Install the package the AI suggested."Does the package exist? Who owns it? Why do we need it?Check the name yourself, and use a built-in tool when one fits.

Practice exercises

5 EXERCISES
Exercise 1 · Warm-upRead the dependency count

Type exactly what this code prints.

Starter codePop out in the code editor (opens in a new tab)JavaScript
const lockfilePackageEntries = 832;
const directDependencyNames = 56;
console.log(lockfilePackageEntries + " vs " + directDependencyNames);

Answer, then press Check. Spacing and letter case don’t matter.

    Exercise 2 · Warm-upCopy the SRI hash

    Type the SRI string for the sample file.

    Starter codePop out in the code editor (opens in a new tab)JavaScript
    console.log("sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e");

    Answer, then press Check. Spacing and letter case don’t matter.

      Exercise 3 · PracticeRead a name check

      Type exactly what the name-check code prints.

      Starter codePop out in the code editor (opens in a new tab)JavaScript
      const result = { candidate: "loadsh", closest: "lodash", distance: 2, suspicious: true };
      console.log(result.closest + " / " + result.distance + " / " + result.suspicious);

      Answer, then press Check. Spacing and letter case don’t matter.

        Exercise 4 · PracticeName what npm audit cannot see

        Type the phrase this code prints.

        Starter codePop out in the code editor (opens in a new tab)JavaScript
        const auditKnows = ["published advisories", "severity", "patched versions"];
        console.log(auditKnows.includes("unreported malware") ? "complete" : "advisory database only");

        Answer, then press Check. Spacing and letter case don’t matter.

          Exercise 5 · ChallengeSwap a tiny package for a built-in tool

          Which built-in tool should replace the tiny package?

          Starter codePop out in the code editor (opens in a new tab)JavaScript
          const tinyPackage = { name: "is-array", native: "Array.isArray" };
          console.log(tinyPackage.native);

          Answer, then press Check. Spacing and letter case don’t matter.

            Check your understanding

            8 QUESTIONS
            Supply-chain security quiz · 8 questionsScore: first tries count
            1. Question 1 of 8What is the software supply chain of a frontend app?

              Choose an answer to see the explanation.

            2. Question 2 of 8What does the real repository count print?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const lockfilePackageEntries = 832;
              const directDependencyNames = 56;
              console.log(lockfilePackageEntries + " vs " + directDependencyNames);

              Choose an answer to see the explanation.

            3. Question 3 of 8Which sentence about npm audit is true?

              Choose an answer to see the explanation.

            4. Question 4 of 8What does the SRI helper print for the sample file?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              console.log("sha384-Tf2FyVXw3trfCQ/AHspPELVdH6/5BICrCwHkiQtu+pcIUQZpzFtsc+Fajj2ob/4e");

              Choose an answer to see the explanation.

            5. Question 5 of 8Why does the checker flag loadsh?

              Read the code, then predictPop out in the code editor (opens in a new tab)JavaScript
              const popularPackages = ["lodash", "react", "express", "next", "axios"];
              function normalizePackageName(name) {
                return name.toLowerCase().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.

            6. Question 6 of 8Which defense fits a CDN script that must never change without you knowing?

              Choose an answer to see the explanation.

            7. Question 7 of 8What does npm install --ignore-scripts help with?

              Choose an answer to see the explanation.

            8. Question 8 of 8Which event best shows an attacker slowly winning trust, outside npm?

              Choose an answer to see the explanation.

            Key takeaways

            • The supply chain is every package, the packages they bring, build tools, registries, CDNs, CI, tokens, and publish steps.
            • npm audit reports published advisories. A lockfile and npm ci make installs repeatable, not safe.
            • SRI makes the browser hash a CDN file and refuse it if the hash differs. It only fits files that never change.
            • Typosquatting, dependency confusion, and slopsquatting all trick you with a name. Check names, scopes, and registry settings.
            • Fewer, better dependencies are easier to check. Use built-in tools, read install scripts, and treat tool results as clues to review.

            One-line summary: treat every new package and outside script like a code change you review, and use hashes, lockfiles, provenance, and fewer packages so you can check what you trust.

            Up next: the isolation and sandboxing lesson shows how to run code you do not trust behind stronger walls, for the times when checking is not enough.

            CompleteFrontend Clear concepts. Working examples.