Supply-chain security for JS teams (without the panic)
A modern JavaScript project is not a codebase. It's a border crossing. A typical client app we inherit declares 40 direct dependencies and drags in 1,800 more transitively — each one software written by strangers, executed with your users' data, updated by whoever controls an npm account at 2 a.m. on a Tuesday.
That framing usually launches one of two unhelpful reactions: panic (freeze everything, buy an appliance, appoint a czar) or shrugging (it's fine, npm would notice). Between those poles is a boring, legible practice a 45-person studio can actually run. This is ours. It fits on one wall of our engineering handbook, and it has caught two genuinely bad packages in three years — one via process, one via luck, which is why we improved the process.
Why JS is special, briefly
Three properties make the JS supply chain different from, say, a Go or Rust one. First, depth: tiny single-purpose packages mean a median dependency tree an order of magnitude deeper than other ecosystems. Second, permission blindness: npm packages run install scripts with your full user privileges, in CI and on your laptop alike. Third, velocity: the culture of weekly minor releases means the "updated by strangers" event happens constantly, quietly, automatically.
None of this is fixable. All of it is manageable. The goal is not "zero risk" — that's how you end up vendoring the internet. The goal is raising the cost of a bad package above the cost of the practice.
The practice, in five parts
1. Lockfiles are law. Commit them, review them, and never let CI regenerate them silently. Every dependency change flows through a pull request that shows the lockfile diff — and we actually read it, or at least skim for the tells: a new transitive dependency in a patch bump of a UI library, a postinstall script appearing where none existed, a version jump from 2.14.1 to 2.14.15 in a package nobody requested. Install with npm ci in CI (never npm install), which fails loudly if the lockfile and manifest disagree. This one habit costs nothing and closes the "CI resolved a different, freshly-published malicious version" hole that lockfile-free builds leave open.
2. A dependency cadence, not a dependency mood. Unscheduled upgrades are how surprise breaking changes and surprise malware both arrive. Ours: a bot opens grouped update PRs every Monday (minor and patch), a human merges them Tuesday after CI, majors get a human-written ticket. Security-critical packages (auth, crypto, anything touching money) are pinned to exact versions and updated deliberately, after a skim of the release notes. Everything else rides semver on the weekly train. The cadence matters more than the tooling — the same discipline argument we make for feature flags in no graveyards allowed.
3. Triage npm audit noise with a severity function. Raw audit output is a boy who cried CVE: hundreds of advisories, most of them "ReDoS in a dev-only dependency of your test reporter." We score each advisory on three questions: Is it in production code? (dev-only downgrades the urgency massively) — Is the vulnerable path actually reachable? (a prototype pollution in a function we call with static strings is not a fire) — Is there an exploit in the wild? Match those against the severity, and a 40-item audit report collapses to maybe two items worth a human hour. Write the triage decision on the ticket. Six months later, "why didn't we fix this" has an answer instead of a shrug.
4. Provenance and hygiene at the edges. Enable 2FA on every publisher account — non-negotiable, audited quarterly. Prefer packages with npm provenance statements (the registry's build-attestation feature, now common among well-run packages). For anything new entering the tree — and this is the highest-leverage rule we have — a human spends five minutes on its npm page and repository: when was it last published, who maintains it, does the install script obfuscate anything, did ownership change hands recently. Five minutes of human judgment filters out the classic account-takeover-novel-crypto-miner pattern more reliably than any scanner we've run. It also feeds the same instinct as our bundle budget reviews: every new dependency should justify its existence to a person.
5. Containment, so one bad package can't end you. Assume a package will eventually go bad and make that survivable. CI runs in ephemeral, secret-scoped environments — the build that publishes the marketing site should not have the production database credentials in its environment. Local dev servers lock to localhost. Production deploys ship static or server-rendered output, so a poisoned build-time dependency has a short half-life (the containment wins compound with the SSR/streaming posture described in streaming SSR notes). Browser-side, a strict Content Security Policy — with nonces, not unsafe-inline — turns "malicious package exfiltrates tokens" into "malicious package fails loudly in the console."
A policy that fits on one page
Here's the wall-ready version we hand clients at handover, right next to the runbook:
BRASSFERN DEPENDENCY POLICY (v3)
1. Lockfiles committed; CI installs with npm ci. No silent resolution.
2. Update train: grouped minor/patch PRs weekly. Majors get tickets.
3. Security-critical deps pinned exact; updated deliberately.
4. Audit triage: prod? reachable? exploited? Write the answer down.
5. New dependency = 5 minutes of human review before merge.
6. Install scripts: ignored by default (npm config set ignore-scripts true),
allowlisted per package when genuinely needed.
7. 2FA on all publisher accounts. Provenance preferred.
8. Emergency: yank-and-pin within 24h of a confirmed compromise;
comms to clients within 48h.
Item 6 deserves a footnote: --ignore-scripts will break a handful of legitimate packages (native builds, sharp/esbuild-style postinstalls). Allowlisting the ten you need is twenty minutes, once, per project. In exchange you neutralise the single most popular malware delivery mechanism in the ecosystem. Best trade in this entire article.
The two near-misses and what they taught us
Since we're asking for trust, the confessional. In 2023 the weekly train's human skim caught a patch release of a mid-size utility package whose diff was 95% benign and 5% an obfuscated loader phoning home from install scripts. The catch wasn't tooling — the scanner missed it — it was the Tuesday skim ritual plus the fact that our reviewer knew roughly what the package's diffs normally look like. Familiarity is a security control.
In 2024 we got lucky: a compromised release of a linter plugin was yanked within four hours, before our weekly train picked it up. Luck is not a practice, so we tightened the one thing that would have helped: newly-published versions now age 72 hours before the bot proposes them, unless flagged as a security fix. The delay costs us nothing and deletes the entire "malicious version live for an afternoon" window that most npm incidents actually occupy.
What we advise clients, by size
Solo founder / seed stage: lockfiles + npm ci + ignore-scripts with an allowlist + weekly update bot. That's it; you're done in an afternoon and ahead of most series-B companies. Scale-up with a platform team: all of the above plus a private registry proxy (which gives you quarantine, caching and an audit trail) and signed provenance on your own published packages. Enterprise handover: the full one-page policy, signed into the runbook, with the emergency yank-and-pin drill rehearsed once. Supply-chain work is exactly the kind of load-bearing boringness our product engineering teams fold into delivery rather than bolt on at audit time — and if your current setup is "we run npm audit when someone remembers," the contact page is a fine next step. For the related discipline of keeping architecture decisions legible over time, see choosing a framework honestly.
Key takeaways
- A JS project is a border crossing: deep trees, privileged install scripts and rapid release cadence define the threat. Manage it, don't panic about it.
- The five-part practice: lockfile law, a scheduled update cadence, audit triage by reachability, human review of new packages, and environment containment.
--ignore-scriptsplus a small allowlist neutralises the most common malware vector for minutes of setup cost.- A 72-hour aging rule for fresh releases deletes the window where most real-world npm compromises live.
- Keep the policy to one page and the cadence weekly — a practice that runs beats a policy that impresses.
FAQ
Isn't npm audit enough on its own?
No — and worse, it misleads. Audit flags known-vulnerable versions regardless of whether your code reaches the vulnerable path, and it misses the dominant modern attack class entirely (a take-over account publishing a clean-looking malicious version isn't a known vulnerability until it's caught). Use it as one input to triage, never as the practice.
Should we pin every dependency to exact versions?
Pin the security-critical and build-critical ones; let the rest ride semver on a weekly train. Pinning everything creates a different hazard — a fossilised tree that's terrifying to update, so nobody does, so the security patches you'd want accumulate into a migration. Stale is its own vulnerability class.
Do lockfile diffs actually help with obfuscated malware?
They help more than you'd think. Most npm incidents announce themselves structurally — new install scripts, unexpected new transitive deps, maintainer changes — before the payload is legible. You don't need to read the obfuscation; you need to notice the shape is wrong and hold the release until the internet reads it for you. The 72-hour rule buys exactly that time.
What about other ecosystems — pip, Composer, Go modules?
Same principles, different defaults. Go's checksum database and minimal version selection make some of this free; Python's install-script problem is similar to npm's. The one-page policy travels well — lockfiles, cadence, triage, human review, containment — because the attacker's economics don't care which registry they phish.
Dependency hygiene: the boring audit that saves the launch
Client codebases rot at the package.json line. Audit cadences, update windows, vendoring decisions, and the licence review nobody does until procurement asks.
Monorepo or not? A studio's decision framework
Monorepos solve code-sharing and coordination problems most studios don't have, at tooling costs they don't predict. When we say yes, and what we do when we say no.
The TypeScript strictness ratchet: stricter every sprint, never a big bang
Strict mode on a legacy codebase is a migration disguised as a flag. The ratchet approach: incremental flags, scoped configs, codemods and type-debt metrics.