# 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.

Source: https://www.jpkc.com/db/en/blog/nodejs-npm-absichern/

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](https://github.com/SocketDev/sfw-free) 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 `postinstall` script that runs automatically on `npm 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`](https://snyk.io/blog/a-post-mortem-of-the-malicious-event-stream-backdoor/) (2018) is the classic: the new maintainer slipped in a dependency that drained crypto wallets. With [`ua-parser-js`](https://en.wikipedia.org/wiki/Supply_chain_attack) (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 (`crossenv` instead of `cross-env`) and waits for a typo in your `package.json` or `npm install` command.
- **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](https://github.com/SocketDev/sfw-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](https://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

```bash
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:

```bash
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:

```bash
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:

```ini
# 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=true
```

**`ignore-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`](https://sharp.pixelplumbing.com/), `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:

```bash
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:

```json
{
  "name": "my-project",
  "private": true,
  "type": "module",
  "engines": {
    "node": ">=24"
  },
  "devDependencies": {}
}
```

- **`"private": true`** — protects against accidental `npm publish`. Without this flag, an erroneous `npm publish` in 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:

```bash
sfw npm ci
```

`npm 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:

```bash
rm -rf node_modules package-lock.json && sfw npm install
```

That 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:

```bash
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:

```bash
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 regressions
```

## Beyond 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:

```bash
sfw npm audit signatures
```

The command verifies the cryptographic registry signatures and — since npm 9.5, where present — **provenance attestations**. Provenance (built on [Sigstore](https://www.sigstore.dev/)) 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:

```bash
sfw npm view <package>      # metadata, maintainers, last publish
sfw npm ls <package>        # shows who pulls <package> in (transitively)
```

On top of that, [socket.dev](https://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`:

```ini
# 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=true
```

`package.json` (excerpt — the security-relevant fields):

```json
{
  "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](https://www.jpkc.com/db/en/blog/claude-code-container/) — isolation on another level: sandboxing build and agent workflows in Podman/Docker.
- [Configuring Claude Code](https://www.jpkc.com/db/en/blog/claude-code-konfiguration/) — setting up permissions, hooks and subagents safely.
- [Cheat Sheets](https://www.jpkc.com/db/en/cheatsheets/) — concise command references, including Node, npm and the command line.
- [Socket Firewall Free](https://github.com/SocketDev/sfw-free) and [socket.dev](https://socket.dev/) — the tools behind this setup.

