# Package Firewall

> A package firewall is a proxy between package managers and registries that blocks malicious or policy-violating package downloads before they reach disk.

Also known as: dependency firewall, repository firewall, registry firewall, package manager firewall, open source firewall, malicious package firewall, supply chain firewall

Source: https://socket.dev/glossary/package-firewall

Last updated: 2026-10-29

A package firewall is a proxy between package managers and registries such as npm and PyPI. It checks every package version the package manager requests, including transitive dependencies, against malware verdicts and policy, and refuses the download when the check fails. Also called a dependency or repository firewall, it stops malicious code before any of it reaches disk.

A package firewall works because an install is just HTTP. The package manager fetches metadata to choose versions, then fetches one tarball per package, and every tarball URL names the package and its version. The firewall looks that pair up and either forwards the request or answers 403. Nothing from a blocked version reaches the machine, so its install scripts never run and its code is never imported.

That turns one verdict into a non-event on every machine at once. In the [axios compromise](https://socket.dev/blog/axios-npm-package-compromised), the payload-carrying dependency `plain-crypto-js@4.2.1` was published at 23:59:12 UTC on March 30, 2026. Socket flagged it as malware at 00:05:41 UTC. The attacker published `axios@1.14.1`, the release that pulled it in, at 00:21:58 UTC. The verdict existed 16 minutes before the poisoned axios did. npm replaced the payload with a security placeholder at 03:25:11 UTC, according to the registry's own `time` field. For 3 hours and 19 minutes, the only thing between a fresh install and a remote access trojan was whether something enforced a verdict that already existed.

We think every install path should go through a package firewall: developer laptops, CI runners, container builds, and AI agents. We also think the category is oversold. A package firewall is a lookup, not a detector. It enforces verdicts that already exist, on traffic it can see, and those two limits decide how much it protects you.

> Timeline of the axios compromise on March 30 and 31, 2026, showing 6 minutes 29 seconds with no verdict and 3 hours 19 minutes in which Socket's verdict existed while npm still served the malicious package

The same timeline as a table. Times are UTC; publish times come from the npm registry's `time` field, and the detection time from Socket's report.

| Time (UTC) | Event | Firewall state |
| --- | --- | --- |
| Mar 30, 23:59:12 | plain-crypto-js@4.2.1 published with a postinstall dropper | No verdict yet; would be allowed |
| Mar 31, 00:05:41 | Socket flags plain-crypto-js@4.2.1 as malware | Verdict exists; download blocked from here on |
| Mar 31, 00:21:58 | axios@1.14.1 published, adding plain-crypto-js as a dependency | Installs of axios@1.14.1 fail on the dependency fetch |
| Mar 31, 01:00:57 | axios@0.30.4 published with the same dependency | Same |
| Mar 31, 03:25:11 | npm replaces plain-crypto-js with 0.0.1-security | Registry no longer serves the payload |

## What is a package firewall?

A package firewall is an enforcement point on the two kinds of HTTP request every package install makes: the metadata request and the tarball request. To see why that is enough, watch what a package manager does on the wire. Here is npm 12.2.1 installing one package that has one dependency:

```console
$ npm install is-odd@3.0.1 --loglevel http 2>&1 | grep 'fetch GET'
npm http fetch GET 200 https://registry.npmjs.org/is-odd 85ms (cache miss)
npm http fetch GET 200 https://registry.npmjs.org/is-number 33ms (cache miss)
npm http fetch GET 200 https://registry.npmjs.org/is-number/-/is-number-6.0.0.tgz 44ms (cache miss)
npm http fetch GET 200 https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz 47ms (cache miss)
```

The first two requests fetch packuments, the registry's metadata document for a package. The resolver reads each one, picks a version that satisfies the declared range, and discovers the next layer of dependencies. The [abbreviated packument format](https://github.com/npm/registry/blob/main/docs/responses/package-metadata.md), requested with `Accept: application/vnd.npm.install-v1+json`, carries what the resolver needs:

```json
{
  "name": "is-odd",
  "dist-tags": { "latest": "3.0.1" },
  "versions": {
    "3.0.1": {
      "dependencies": { "is-number": "^6.0.0" },
      "dist": {
        "tarball": "https://registry.npmjs.org/is-odd/-/is-odd-3.0.1.tgz",
        "integrity": "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA=="
      }
    }
  }
}
```

Nobody asked for `is-number`. npm fetched it because `is-odd` declares `^6.0.0`, which is how every [transitive dependency](https://socket.dev/glossary/transitive-dependency) enters a build. The last two requests download tarballs. npm hashes each tarball, compares the digest to `dist.integrity`, and only then extracts it and runs whatever [install scripts](https://socket.dev/glossary/postinstall-scripts) the package declares and the configuration allows.

A package firewall sits where those requests go, and it has exactly two places to act. At the metadata request, it can remove a blocked version from the packument so the resolver never selects it. At the tarball request, it can refuse the download. The integrity hash is why there is no third option. A proxy that rewrote a tarball to strip a payload would also have to rewrite `dist.integrity`. That breaks every lockfile that already records the original digest, and it invalidates the [registry signature](https://docs.npmjs.com/about-registry-signatures), which signs `name@version:integrity`. A package firewall that changed bytes would be indistinguishable from the attack it exists to stop. In practice, a package firewall can allow, hide, or deny. It cannot sanitize.

> Sequence diagram of an npm install through a package firewall: the metadata request, where the firewall can hide blocked versions, and the tarball request, where it answers 403 without contacting the registry; a local cache hit makes no request at all

The diagram shows one install through a package firewall in three parts. First, the metadata request: npm asks the firewall for `/ms`, the firewall fetches the packument from the registry, optionally removes blocked versions, and returns the result, from which npm's resolver picks a version satisfying `^2.1.0`. Second, the tarball request: npm asks for `/ms/-/ms-2.1.3.tgz`, and the firewall looks up a verdict for `ms@2.1.3`. If the version is blocked, the firewall answers 403 and never contacts the registry. If it is allowed, the firewall forwards the request and returns the registry's tarball with its bytes unchanged, and npm verifies its SHA-512 digest against `dist.integrity` before extracting it. Third, a local cache hit: npm already has the tarball, makes no request, and the firewall never sees the install.

## Why can't a network firewall block a malicious package?

A network firewall cannot block a malicious package because it sees only the registry's hostname, and good and bad packages come from the same host. The package identity lives in the URL path, `/plain-crypto-js/-/plain-crypto-js-4.2.1.tgz`, and HTTPS encrypts the path. A network firewall can read the destination address and the server name the client sends in the TLS handshake ([SNI, RFC 6066](https://www.rfc-editor.org/rfc/rfc6066#section-3)). For lodash and for malware alike, that name is `registry.npmjs.org`. An egress rule that permits that host permits every package on it. A rule that blocks it breaks every build.

The older vocabulary for these controls, whitelisting and blacklisting, comes from that network and endpoint world: lists of hosts, ports, or executable hashes a system will admit or refuse. A web application firewall (WAF) is the HTTP-aware version, but it inspects requests coming into your application, not your build's outbound fetches. A package firewall applies the list idea one layer up. It is an application-layer proxy that speaks registry protocols, so it can key decisions on ecosystem, package name, and version instead of IP address. To read the path, it either terminates TLS itself with a certificate the package manager has been told to trust, or it becomes the registry the package manager talks to. Those two choices are the deployment modes below.

## How does a package firewall block a malicious npm package?

A package firewall blocks a malicious npm package by hiding the version from metadata, refusing its tarball with HTTP 403, or both. The difference matters more than most descriptions admit, so we built the smallest package firewall that shows it. It proxies `registry.npmjs.org`, treats `ms@2.1.3` as malware, and filters metadata when `FILTER=1`. `ms@2.1.3` is a harmless version standing in for a malicious one. We picked it because `^2.1.0` has older versions to fall back to, exactly as `^1.14.0` had `axios@1.14.0`.

```javascript
// A toy package firewall for npm: a registry proxy with a blocklist.
import http from "node:http";

const UPSTREAM = "https://registry.npmjs.org";
const SELF = "http://localhost:4873";
const BLOCKED = new Set(["ms@2.1.3"]);       // verdicts: name@version
const FILTER_METADATA = process.env.FILTER === "1";

http.createServer(async (req, res) => {
  const url = new URL(req.url, SELF);
  const tgz = url.pathname.match(/^\/((?:@[^/]+\/)?[^/]+)\/-\/[^/]+-(\d[^/]*)\.tgz$/);

  if (tgz) {                                  // 1. tarball download
    const id = `${decodeURIComponent(tgz[1])}@${tgz[2]}`;
    if (BLOCKED.has(id)) {
      console.log("BLOCK", id);
      res.writeHead(403, { "content-type": "text/plain" });
      return res.end(`Blocked by policy: ${id} is known malware\n`);
    }
    console.log("ALLOW", id);
    const up = await fetch(UPSTREAM + url.pathname);
    res.writeHead(up.status, { "content-type": "application/octet-stream" });
    return res.end(Buffer.from(await up.arrayBuffer()));
  }

  // 2. metadata ("packument") request
  const up = await fetch(UPSTREAM + url.pathname, {
    headers: { accept: req.headers.accept ?? "application/json" },
  });
  if (!up.ok) { res.writeHead(up.status); return res.end(); }
  const doc = await up.json();
  for (const [v, meta] of Object.entries(doc.versions ?? {})) {
    if (FILTER_METADATA && BLOCKED.has(`${doc.name}@${v}`)) {
      delete doc.versions[v];
      console.log("HIDE ", `${doc.name}@${v}`);
      continue;
    }
    meta.dist.tarball = meta.dist.tarball.replace(UPSTREAM, SELF);
  }
  for (const [tag, v] of Object.entries(doc["dist-tags"] ?? {})) {
    if (!doc.versions?.[v]) delete doc["dist-tags"][tag];   // no tag may point at a hidden version
  }
  res.writeHead(200, { "content-type": "application/json" });
  res.end(JSON.stringify(doc));
}).listen(4873, () => console.log("firewall on", SELF, FILTER_METADATA ? "(filtering)" : ""));
```

**Run 1: tarball blocking only.** A fresh install of a range, with metadata filtering off:

```console
$ export npm_config_audit=false npm_config_fund=false
$ node firewall.mjs &
$ npm install 'ms@^2.1.0' --registry http://localhost:4873/ --cache ./cold
npm error code E403
npm error 403 403 Forbidden - GET http://localhost:4873/ms/-/ms-2.1.3.tgz
npm error 403 In most cases, you or one of your dependencies are requesting a package version that is forbidden by your security policy, or on a server you do not have access to.
```

The resolver picked 2.1.3 because the packument still listed it, and the tarball request hit the block. npm's own E403 text already anticipates this deployment: "forbidden by your security policy." The install fails closed, which is the correct security outcome. It is also a broken build that a developer now has to fix by hand.

**Run 2: metadata filtering on.** The same install:

```console
$ FILTER=1 node firewall.mjs &
$ npm install 'ms@^2.1.0' --registry http://localhost:4873/ --cache ./cold
added 1 package in 852ms
$ grep -A1 '"node_modules/ms"' package-lock.json
    "node_modules/ms": {
      "version": "2.1.2",
```

The resolver never saw 2.1.3 and chose 2.1.2. That is the behavior you want during an incident like axios: ranges keep resolving, to the newest version that has no block.

**Run 3: a lockfile that already names the bad version.** The [lockfile](https://socket.dev/glossary/lockfile) was created earlier by a direct install from the public registry, which also filled a cache directory called `./warm`. Metadata filtering is still on, and this run uses an empty cache:

```console
$ grep -A2 '"node_modules/ms"' package-lock.json
    "node_modules/ms": {
      "version": "2.1.3",
      "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
$ npm ci --registry http://localhost:4873/ --cache ./cold
npm error code E403
npm error 403 403 Forbidden - GET http://localhost:4873/ms/-/ms-2.1.3.tgz
```

Metadata filtering did nothing here, because `npm ci` never asked for metadata. The lockfile names the exact tarball, so only the download check could stop it. The lockfile's `resolved` URL points at `registry.npmjs.org`, yet the request went to the firewall. npm's [`replace-registry-host`](https://docs.npmjs.com/cli/v12/using-npm/config#replace-registry-host) setting defaults to `npmjs`, which rewrites public-registry URLs in a lockfile to the configured registry. A package firewall deployed as the registry therefore covers existing lockfiles without anyone editing them.

Two conclusions follow. First, hiding versions is a usability feature and the tarball check is the security boundary. Lockfiles, direct tarball URLs, and cached metadata all skip the listing. Socket's registry-mode documentation draws the same line: metadata filtering is off by default, and "Listing removal does not stop installs. Downloads are checked at download time" ([configuration reference](https://docs.socket.dev/docs/socket-firewall-enterprise-registry-mode-configuration-reference)). Second, metadata filtering is easy to get subtly wrong. Our first draft of the toy repointed the `latest` dist-tag at the last remaining key in `versions` when it hid the real latest. `npm install ms` then resolved to `4.0.0-nightly.202508271359`. The version above deletes any tag that points at a hidden version, and npm falls back to the highest stable release, 2.1.2.

## Where a package firewall sits

A package firewall only protects traffic that passes through it, so placement decides coverage. Three placements are in common use, and as of October 2026 Socket Firewall supports all three.

**Wrapper mode** runs the firewall per command. `sfw npm install` starts an ephemeral HTTP proxy, routes that subprocess's package traffic through it, and checks each request against the Socket API before the package is fetched ([Socket Firewall launch](https://socket.dev/blog/introducing-socket-firewall)). It needs no infrastructure, which makes it the right tool for a laptop or a CI job. It covers nothing the wrapper does not launch. Since December 2025, [Docker Hardened Images](https://socket.dev/blog/socket-firewall-now-available-in-docker-hardened-images) for Node.js, Python, and Rust ship with `sfw` already wired into npm, yarn, pnpm, pip, uv, and cargo, so dependency installs inside those container builds are wrapped by default.

**Registry mode** runs the firewall as a long-lived service that package managers use as their registry, for example `npm config set registry https://firewall.company.com/npm/`. Every configured client goes through it, including `npm ci` from an existing lockfile, and one host can front npm, PyPI, Maven, Cargo, RubyGems, NuGet, Go, Conda, and Open VSX ([registry mode docs](https://docs.socket.dev/docs/socket-firewall-enterprise-registry-mode)).

**Upstream mode** puts the firewall behind an [artifact repository](https://socket.dev/glossary/artifact-repository), the internal mirror many companies already run, so the mirror pulls through it and developer configuration does not change. The cost is stated in Socket's own [upstream deployment guide](https://docs.socket.dev/docs/socket-firewall-enterprise-registry-mode-upstream-deployment-guide): packages already cached in the mirror are served without ever reaching the firewall, and the firewall scans each package version once. A version that entered the mirror before its verdict stays servable from the mirror's cache.

| Placement | What passes through it | What bypasses it | Best fit |
| --- | --- | --- | --- |
| Wrapper (sfw npm install) | Commands launched by the wrapper | Unwrapped installs; local package-manager cache hits | Laptops, individual CI jobs, container builds |
| Registry mode (downstream) | Every request from every client configured to use it, lockfile installs included | Clients still pointed at the public registry; local cache hits | Organization-wide enforcement |
| Upstream of a mirror | The mirror's cache misses | Everything already cached in the mirror, plus local cache hits | Adding checks without touching client configuration |

Our verdict: make registry mode the default for an organization, because it sees every request from every configured client, including installs from existing lockfiles. Use wrapper mode where you cannot change registry configuration. Put a package firewall upstream of a mirror only if you also purge the mirror's cache when a verdict changes. Without that, it is an admission check on first fetch, not a firewall.

Placement also decides what happens when the firewall cannot reach its verdict service. Socket's registry mode defaults to `fail_open: true`, which allows packages with a warning when the API times out or errors, and its docs recommend failing closed for production. Unknown versions get a separate switch, `fail_open_unscanned`, which also defaults to allowing. Cached results stay fresh for 600 seconds by default, so a new verdict can take up to that long to reach a version the firewall has already allowed ([configuration reference](https://docs.socket.dev/docs/socket-firewall-enterprise-registry-mode-configuration-reference)). Those defaults choose availability for development and CI. Builds that ship to customers should choose enforcement.

## Should a package firewall use an allowlist or a blocklist?

A package firewall should block by verdict and reserve exact-version allowlists for the few systems that can afford to approve every package. A blocklist, historically called a blacklist, is default-allow: refuse what is known to be bad and admit the rest. An allowlist, historically a whitelist, is default-deny: admit only approved packages and versions.

Allowlisting works well for application control on endpoints, where the set of approved executables is small and changes slowly. A dependency tree is neither. Every caret range is a standing request for versions that did not exist when anyone approved the package, and one minor release can add new transitive dependencies. An exact-version allowlist turns each of those into an approval ticket. In practice that decays into approving whole packages, and an allowlist entry for `axios` would have admitted `axios@1.14.1`.

Most deployments sit between the two: policy over verdicts. Block confirmed malware, hold versions younger than a threshold, and decide what to do with versions nobody has scanned yet.

| Policy | Unknown new version | Friction | What it misses |
| --- | --- | --- | --- |
| Blocklist of known malware | Allowed | Low | Anything not yet flagged |
| Exact-version allowlist | Denied until approved | High: every new transitive version needs approval | Malicious releases of approved packages, once approvals drift to whole packages |
| Verdicts plus a cooldown | Held until it is N days old, then allowed if no verdict | Moderate: urgent patches need an exception | Malware that stays hidden longer than the cooldown |

Our verdict: block confirmed malware everywhere, add a cooldown, and keep exact-version allowlists for high-assurance systems such as release-signing hosts. Socket's [cool-down policy](https://docs.socket.dev/docs/cool-down-policy) raises a `recentlyPublished` alert for versions younger than an org-wide threshold of 0 to 365 days, and Socket Firewall blocks the request when the policy action is `error`. A [dependency cooldown](https://socket.dev/glossary/dependency-cooldown) enforced in the firewall has one property a package-manager setting lacks: a project's `.npmrc` cannot turn it off.

## Package firewall vs software composition analysis

A package firewall and [software composition analysis](https://socket.dev/glossary/software-composition-analysis) (SCA) answer different questions at different times. SCA reads the manifests and lockfiles in a repository, usually on a pull request or a schedule, and reports what the project declares. A package firewall sees what machines actually fetched, including installs no repository declares: a tool run with [npx](https://socket.dev/glossary/npx-npm-package-runner) in a CI step, a global install, a package an agent added and later removed.

Our analysis of [the hidden blast radius of the axios compromise](https://socket.dev/blog/hidden-blast-radius-of-the-axios-compromise) framed the exposure question as "Did anything you executed resolve Axios during that window?" A lockfile cannot answer that. Darcy Clarke, founder of vlt and a former npm engineering manager, explained why in that post: "Lockfiles don’t prevent net-new installs when updating/adding new dependencies. That’s the point." A package firewall's log can answer it. Socket Firewall Enterprise records each attempted download with the machine ID, the time, and whether it was allowed or blocked ([Socket Firewall Enterprise](https://socket.dev/blog/socket-firewall-enterprise)).

Neither a package firewall nor SCA should key on CVEs for this class of attack. [Malicious packages](https://socket.dev/glossary/malicious-package) do not get CVEs, so a policy driven by CVE severity would have allowed `plain-crypto-js@4.2.1` at every step.

Package manager defaults are moving the same direction. [npm v12](https://socket.dev/blog/npm-12) shipped with dependency install scripts off by default in July 2026, following [pnpm 10](https://socket.dev/blog/pnpm-10-0-0-blocks-lifecycle-scripts-by-default). That removes the automatic execution path, and we think it was overdue. It does not keep the code off disk. A skipped `postinstall` leaves the package in `node_modules`, where anything that imports it runs it. A package firewall is the control that keeps the bytes out.

## What a package firewall cannot see

A package firewall cannot see installs that never make a request. Run 4 repeats Run 3 with one change: it uses the `./warm` cache that the earlier direct install populated.

```console
$ npm ci --registry http://localhost:4873/ --cache ./warm --loglevel http
npm http cache ms@http://localhost:4873/ms/-/ms-2.1.3.tgz 0ms (cache hit)
added 1 package in 460ms
$ node -p "require('./node_modules/ms/package.json').version"
2.1.3
```

The firewall logged nothing. npm matched the lockfile's integrity hash against its content-addressed cache and installed the blocked version without a single request. Socket's [documentation for the free tier](https://docs.socket.dev/docs/socket-firewall-free) says the same and recommends clearing the package manager's cache before first use. Ephemeral CI runners and fresh container layers mostly avoid this; long-lived laptops do not.

A package firewall cannot block what has no verdict. `plain-crypto-js@4.2.1` was installable for 6 minutes and 29 seconds before Socket flagged it. As of October 2026, Socket Firewall Free blocks only malware confirmed by human review and warns on AI-detected malware without blocking it. In the [tinycolor compromise](https://socket.dev/blog/tinycolor-supply-chain-attack-affects-40-packages), Socket's AI flagged the malware within an hour of publication ([Socket Firewall launch](https://socket.dev/blog/introducing-socket-firewall)). Under the free tier's policy, that detection would have produced a warning, and a block only after a human confirmed it.

A package firewall checks the artifact that was requested, not the code inside it. Socket found [`@shadanai/openclaw`](https://socket.dev/blog/axios-npm-package-compromised) shipping the same plain-crypto-js dropper deep in a vendored path of its own tarball. No request for plain-crypto-js ever happens in that install, so protection depends on a verdict for `@shadanai/openclaw` itself. That verdict has to come from analyzing what is actually in the tarball, not from its manifest or its declared dependencies.

A package firewall cannot see code that never comes from a registry. Git dependencies and remote tarball URLs bypass registry routing entirely. npm v12 now refuses both by default unless `--allow-git` or `--allow-remote` permits them. The plain-crypto-js dropper fetched its second stage from the attacker's server on port 8000, not from any registry. The firewall's job was to stop the first stage, because nothing in the package path would ever see the second.

The open problem is the version nobody has analyzed yet. A package firewall that fails open on unscanned versions admits it. One that fails closed breaks builds whenever a dependency ships a release faster than analysis finishes. A short cooldown, enforced in the firewall, is the best compromise we know of. It turns "unknown" into "not yet" for long enough that analysis catches up, and it leaves detection speed as the number that matters.
