Working Securely with Node.js and npm: My Setup Against Supply-Chain Attacks
How I harden Node.js and npm projects against supply-chain attacks: Socket Firewall with a walkthrough, .npmrc, a hardened package.json, npm ci, provenance signatures — plus a copy-paste starter.
by Jean Pierre Kolb ·
This site is a static website. No PHP, no database, no server code running at request time — in the end it is just HTML, CSS and a bit of JavaScript. You might think there is nothing here to secure. The fallacy hides in the words "at request time": the dangerous moment is not when a visitor loads the page, but when I build it. In that very second, npm pulls dozens, often hundreds of foreign packages onto my machine and — by default — is allowed to execute arbitrary code. That is the attack surface.
This article is my complete setup against it, exactly as I run it across all jpkc.com projects: Socket Firewall as an npm wrapper, a hardened .npmrc, a defensively configured package.json, reproducible installs — and beyond that the measures that go past the project config itself (signatures, account security, dependency hygiene). At the end there is a copy-paste starter, a command cheat sheet and an FAQ. You can apply the setup directly to your own Node projects — static site, CLI tool or app alike.
The threat model in five minutes
Before touching config, it helps to look at what we are actually defending against. "Supply-chain attack" means: it is not your own software that gets attacked, but something you trust and include — a package, a transitive dependency, a maintainer account. Four patterns recur again and again:
- Malicious install hooks. A package defines a
postinstallscript that runs automatically onnpm install— before you even use any of it. One of the most common vectors. Almost every known incident exfiltrated data or persisted through it. - Compromised versions. A legitimate, popular package ships a new version with embedded malicious code — because the maintainer account was hijacked or the project was handed off.
event-stream(2018) is the classic: the new maintainer slipped in a dependency that drained crypto wallets. Withua-parser-js(2021) the account was hijacked and versions carrying a crypto miner and password stealer were published. - Typosquatting. A malicious package is named almost like a well-known one (
crossenvinstead ofcross-env) and waits for a typo in yourpackage.jsonornpm installcommand. - Dependency confusion. An attacker registers, on the public registry, a name you use internally, with a higher version number — and npm pulls the public, malicious variant instead of your internal one.
The common denominator: the damage happens on your machine or in your CI, at install and build time. That is exactly where the setup intervenes.
Building block 1: Socket Firewall (sfw)
The most important rule first, because it wraps all the others: I never call npm directly — always through sfw.
Socket Firewall Free is a lean wrapper around npm. It slots itself between your command and the registry and analyses every package fetch in real time against the threat data from socket.dev. If it recognises a known malicious package, it blocks it before it is ever written to disk — that is, before a postinstall hook would even get a chance to run. This is the crucial difference from npm audit: the firewall acts proactively on the fetch, audit checks the already installed graph reactively.
Installation
npm install -g sfw # one-time, installs the wrapper globally
sfw --version # verify, e.g. "Socket Firewall Free, version 2.0.6"A small chicken-and-egg moment that honestly belongs here: you install sfw itself once with plain npm — it is, after all, the tool that only afterwards secures every further install. You make that one call deliberately and from a trusted source (the official sfw package); from then on everything else runs through the firewall.
Usage
From here on every npm call gets the sfw prefix:
sfw npm ci # install from the lockfile
sfw npm run build # production build
sfw npm install <package>The setup also assumes a current Node — I enforce Node.js ≥ 24 (see building block 3). Check:
node --version # should be v24.x or higher Building block 2: .npmrc — two lines that make the difference
In the project root sits an .npmrc with exactly two settings. Both are short, both are effective:
# Block install hooks — the most common npm supply-chain vector.
ignore-scripts=true
# Pin exact versions on install; no caret/tilde ranges in package.json.
save-exact=trueignore-scripts=true blocks the preinstall, postinstall and prepare scripts of every package. That closes one of the most common attack vectors — a malicious package simply can no longer run its code automatically on install. This one line catches the majority of the real, documented supply-chain compromises.
save-exact=true makes npm install <package> write an exact version into package.json — no ^1.2.3, no ~1.2.3. The effect: a dependency update becomes a deliberate, auditable action instead of a silent side effect. Without this line, an npm install could quietly pull in a freshly published (and possibly compromised) patch version.
The special case: native binaries
ignore-scripts=true comes at a price. Some packages with native components — such as sharp, esbuild, pagefind, lightningcss or @tailwindcss/cli — used to need a postinstall to fetch their platform-specific binary. Most now ship it as a ready-made platform package via optionalDependencies. The neat part: even packages that still declare an install script — esbuild with a postinstall, older sharp versions with an install script — work fine under ignore-scripts=true, because the binary arrives through the optional dependency and the neutralised script is never needed. But if a binary is missing after sfw npm ci and the build breaks, I pull the one affected package in deliberately and once:
sfw npm install --ignore-scripts=false <package>Important: this is a surgical exception for a single, known package — not a blanket disabling of ignore-scripts. The global setting stays on.
Building block 3: hardening package.json
Three fields that close off attack and mistake surfaces:
{
"name": "my-project",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"devDependencies": {}
}"private": true— protects against accidentalnpm publish. Without this flag, an erroneousnpm publishin the project folder would upload your (possibly internal) configuration to the public npm registry. A typo with public consequences."engines": { "node": ">=24" }— documents and enforces the Node version. Newer Node versions bring security fixes and modern defaults; a minimum version prevents anyone from building the project on an outdated, vulnerable Node.- All dependencies under
devDependencies— for a static site the whole stack is a build tool. There are no runtime dependencies, because no Node runs at request time. Keeping that clean holds the attack surface of what would actually ship at zero.
Building block 4: npm ci instead of npm install — and lockfile hygiene
For the standard case — "install what the project prescribes" — I never use npm install, but:
sfw npm cinpm ci installs exactly what is in package-lock.json: no re-resolution of version ranges, no surprises, reproducible and faster on top. npm install, by contrast, may re-resolve the tree and — depending on ranges — pull different versions than last time. With npm ci the lockfile is the single source of truth.
For that to hold: package-lock.json gets committed and is treated like source code. And when I really do want to rebuild the tree from scratch once:
rm -rf node_modules package-lock.json && sfw npm installThat is the only moment npm install is used — deliberately, to produce a fresh lockfile that I then review and commit.
Building block 5: a maintenance cadence
Security is not a one-time setup. Once a week, two commands run here:
sfw npm audit # known CVEs in the dependency graph
sfw npm outdated # outdated packages (empty output = all current)npm audit matches the installed graph against the CVE database and reports known vulnerabilities with their severity. Non-invasive fixes (lockfile only) are applied by sfw npm audit fix; --force would also pull breaking-change updates — only with care and a build test afterwards.
npm outdated shows where newer versions are available. I update deliberately, not wholesale:
sfw npm view <package> version # check the current version
sfw npm install <package>@<new-version> # update deliberately
sfw npm audit # check afterwards
sfw npm run build # rule out regressionsBeyond the setup
The five building blocks above are the foundation. If you take it seriously, you add four things that go past the project config itself.
Verify signatures and provenance
Since npm 8.15 you can check whether the installed packages really are what the registry signed:
sfw npm audit signaturesThe command verifies the cryptographic registry signatures and — since npm 9.5, where present — provenance attestations. Provenance (built on Sigstore) provably links a published package to the exact source commit and the CI build it came from. A green result means: what you installed demonstrably comes from the claimed source and was not tampered with along the way.
Account security
Most of the big incidents began with a hijacked maintainer account, not a code flaw. If you publish yourself, protect your npm account with 2FA (two-factor authentication) and use granular access tokens with minimal rights and an expiry date instead of one all-powerful, never-expiring token. A token allowed to publish only a single package does little harm if it leaks.
Minimise dependencies — and vet them before adding
The safest dependency is the one you do not have. Every package brings its own dependency tree; an innocent-looking helper quickly drags in a dozen transitive ones. Before a package comes in, a quick look pays off:
sfw npm view <package> # metadata, maintainers, last publish
sfw npm ls <package> # shows who pulls <package> in (transitively)On top of that, socket.dev gives each package a score and flags suspicious behaviour (network access, shell execution, install scripts). Criteria I check: how old and active is the package? How many maintainers? Does it really need install scripts? Is a one-line benefit worth a large transitive tree?
CI as hard as local
The build often runs not on my machine but in CI in the end — and the same rules apply there: npm ci (never install), the .npmrc with ignore-scripts is respected in the pipeline too, and deploy tokens get only the rights they need. A compromised dependency in a CI with far-reaching secrets is considerably more dangerous than locally.
Which building block covers which vector
| Attack vector | Countermeasure in the setup |
|---|---|
Malicious install script (postinstall) | ignore-scripts=true |
| Compromised new version | save-exact=true + npm ci from the lockfile |
| Typosquatting / known malware package | Socket Firewall blocks on fetch |
| Accidental publish | private: true |
| Unnoticed transitive CVE | sfw npm audit (weekly) |
| Forged / tampered artefact | sfw npm audit signatures (provenance) |
| Hijacked publishing account | npm 2FA + granular tokens |
| Bloated attack surface | minimise dependencies, everything as devDependencies |
Copy-paste starter
For a new project these two files are enough as a secure foundation.
.npmrc:
# Block install hooks — the most common npm supply-chain vector.
ignore-scripts=true
# Pin exact versions on install; no caret/tilde ranges in package.json.
save-exact=truepackage.json (excerpt — the security-relevant fields):
{
"name": "my-project",
"private": true,
"type": "module",
"engines": {
"node": ">=24"
},
"devDependencies": {}
}Then install sfw globally once (npm install -g sfw), and from there every npm call runs through sfw.
Command cheat sheet
| Command | Purpose |
|---|---|
sfw npm ci | Reproducible install from the lockfile (default case) |
sfw npm install <package> | Add a new dependency (exactly pinned) |
sfw npm install --save-dev <package> | Explicitly as a devDependency |
sfw npm install --ignore-scripts=false <package> | Pull in a missing native binary once |
sfw npm audit | Check known CVEs in the dependency graph |
sfw npm audit signatures | Verify registry signatures and provenance |
sfw npm outdated | Show outdated packages |
sfw npm ls <package> | Show who pulls <package> in as a (transitive) dependency |
sfw npm view <package> | Metadata and versions of a package |
FAQ
Does sfw noticeably slow down installs? No. The fetch analysis is negligible next to the actual network download. The security gain is out of all proportion to the minimal overhead.
After sfw npm ci the build breaks because a binary is missing — what now? That is the native-binaries special case. Pull the one affected package in once with sfw npm install --ignore-scripts=false <package>. ignore-scripts stays on globally.
Do I really need this for a purely static website? Yes. The attack happens at build time on your machine or in CI, not at request time on the shipped site. Whether the output is HTML or an app changes nothing about the install attack surface.
Is sfw a replacement for npm audit? No, the two complement each other. sfw blocks proactively on fetch (known malware, before it hits disk), npm audit checks the installed graph reactively against CVE databases. Together they cover prevention and detection.
Does Socket Firewall cost anything? Socket Firewall Free is free. There are paid Socket offerings for teams beyond that, but the hardening described here runs entirely on the Free variant.
Further reading
- Claude Code in a container — isolation on another level: sandboxing build and agent workflows in Podman/Docker.
- Configuring Claude Code — setting up permissions, hooks and subagents safely.
- Cheat Sheets — concise command references, including Node, npm and the command line.
- Socket Firewall Free and socket.dev — the tools behind this setup.