The preinstall, install, and postinstall lifecycle scripts in npm are, functionally, arbitrary code execution at install time. Every person who writes a Dockerfile that runs npm install has accepted this bargain, whether they realize it or not. Over the years I have watched crypto miners, credential stealers, environment variable exfiltration, and at least one attempt to modify /etc/hosts during a postinstall. None of these were exotic; all were detected only because something downstream went wrong and someone looked at install logs.
Sandboxing npm scripts is not one technique. It is a spectrum, and the right answer depends on your threat model and your tolerance for breakage. I'll walk through the options I've actually used, from the cheapest to the most involved, with the failure modes I've seen in each.
The Cheapest Win: --ignore-scripts
Running npm install --ignore-scripts disables every lifecycle hook. This is the single highest-impact change you can make, and most projects don't actually need scripts at install time. In January 2023, a survey of the top 10,000 packages by weekly downloads found that roughly 14 percent declared lifecycle scripts, and of those, fewer than a third had genuine install-time requirements (most of the rest were leftover scaffolding or optional build steps).
The typical failure when you flip this on is that some native module, like bcrypt or sharp before its prebuilt-binaries era, fails to compile. The fix is to rely on prebuilt binaries, which sharp has shipped since version 0.32 in May 2023, or to use a sibling package that bundles them. For packages that genuinely need to compile, maintain a narrow allowlist via npm config set ignore-scripts true combined with a per-package exception using a wrapper like dlx install or a committed build step.
Dedicated Install Users
On Linux CI, the next tier is running npm install as a restricted user. The node:lts Docker image historically ran as root; since Node 20 the images ship with a node user that the image recommends you USER node into. Running install as an unprivileged user means a malicious postinstall cannot write to system paths or read root-owned secrets. This is cheap and effective against the most common classes of supply-chain malware.
The caveat: many CI systems mount secrets as files readable by the build user. If your install user can read /run/secrets/npm_token, a malicious postinstall can exfiltrate it. The fix is to keep install phases separate from phases that need secrets. Install first, as an unprivileged user with no network egress except to the registry, then switch to a phase that has secrets but no untrusted code execution.
Seccomp And AppArmor
On a Linux CI host you control, seccomp profiles and AppArmor can further restrict what syscalls the install can make. Docker accepts --security-opt seccomp=profile.json and --security-opt apparmor=profile. I've used a custom seccomp profile to block ptrace, kexec_load, clone with namespace flags, and the full set of mount syscalls. This is enough to stop most escape attempts from a malicious script.
The work here is not in writing the profile, which takes an afternoon; it is in tuning it so it doesn't break legitimate native builds. node-gyp compiles invoke execve on a broad set of binaries and open on a lot of unexpected paths. Expect a week of trial-and-error before a locked-down profile survives a full matrix build.
Network Egress Control
A postinstall that cannot reach the internet cannot exfiltrate anything. Running install in a network namespace that only allows egress to registry.npmjs.org and any mirrors you configure is the single best control I know for data theft during installs. On Kubernetes, a NetworkPolicy scoped to the CI namespace accomplishes this. On a GitHub Actions runner, a runs-on with a pre-configured egress firewall (Actions Runner Controller supports this) does the same.
The 2022 node-ipc protestware incident, where the maintainer shipped a version that overwrote files on machines located in Russia and Belarus, is the canonical reason to do this. The payload in that case ran without needing network access, so egress control would not have prevented the file overwrites, but egress control has prevented every credential-stealer I have personally investigated since then.
Full Sandboxing: Firecracker, gVisor, And VMs
At the far end of the spectrum, run npm install inside a microVM or a user-space kernel sandbox. AWS's Firecracker, originally announced in November 2018 and used by Lambda and Fargate, is the tool most engineering teams reach for. Google's gVisor is the other option; it provides a user-space kernel that intercepts syscalls and re-implements them in Go, reducing the attack surface of the host kernel.
The gVisor approach is easier to adopt because it slots in as a Docker runtime (--runtime=runsc). The cost is performance: I've measured npm install taking 1.7 to 2.3 times longer under gVisor than under runc. For CI jobs that run install once and then cache, this is tolerable. For local development it is not.
Firecracker gives you stronger isolation at the cost of operational complexity. You are running VMs with their own kernels, which means you need to manage kernel updates, root filesystem snapshots, and network setup. I've used Firecracker in production for a build farm that processes untrusted user-submitted projects. It is appropriate there; it would be overkill for a typical monorepo's CI.
The lavamoat Approach
LavaMoat, maintained by MetaMask and open-sourced in 2020, takes a different angle. Rather than sandboxing the install at the OS level, it loads each dependency inside a SES (Secure ECMAScript) realm with a policy file declaring what globals and modules that dependency is allowed to access. The @lavamoat/allow-scripts tool blocks install scripts by default and requires an explicit per-package allowlist.
I've used @lavamoat/allow-scripts in half a dozen projects. The allowlist file is straightforward: the first install after you add LavaMoat produces a report of every package that wanted to run a script, and you decide which to permit. It is the lowest-friction way I know to actually stop unknown postinstalls without breaking a build.
What I Actually Recommend
For most teams: --ignore-scripts with @lavamoat/allow-scripts for the exceptions, running as a non-root user, inside a container with NetworkPolicy restricting egress to the registry. That combination blocks the attacks I see in the wild without requiring a microVM operations effort.
For teams processing untrusted code (CI-as-a-service, package build bots, auto-merge bots): go further, to gVisor or Firecracker. Nothing less is defensible when you cannot trust the input.
How Safeguard Helps
Safeguard scans lifecycle scripts in every package in your dependency graph and flags packages that declare preinstall, install, or postinstall hooks, prioritizing those published in the last 30 days or by maintainers new to the package. Our policy engine can block merges that introduce a new dependency with lifecycle scripts unless the PR includes an explicit acknowledgment, which mirrors the @lavamoat/allow-scripts pattern at organizational scale. For teams running in Kubernetes, Safeguard integrates with admission controllers to prevent pods from pulling npm packages that have unapproved scripts. The goal is to make script execution a reviewed decision, not a default behavior.