The recent software supply-chain attacks proof once again that the npm ecosystem is a double-edged sword. With over 2 million packages available, developers can build applications faster than ever before. But this convenience comes with a significant security risk. When a single compromised package can affect thousands of downstream projects, we need better defenses.
In this post, I'll show you how combining npm lock files with the --ignore-scripts flag creates a powerful security layer that can protect your projects from many common attack vectors.
The growing threat of supply chain attacks
Supply chain attacks in the npm ecosystem aren't theoretical—they're happening regularly. In recent years, we've seen high-profile incidents like the event-stream compromise, where a popular package was hijacked to steal Bitcoin wallets, and the ua-parser-js attack, where malicious code was injected to install cryptominers and password stealers.
These attacks often follow a pattern: malicious code executes during npm install through lifecycle scripts. The moment you run npm install, packages can execute arbitrary code on your machine via preinstall, install, or postinstall hooks. This happens before you've even run your application.
The problem is amplified by transitive dependencies. Install one package, and you might be pulling in dozens or hundreds of dependencies you've never heard of. Each one is a potential attack vector.
Your first line of defense: lock files
Most developers understand that package-lock.json ensures reproducible builds, but fewer recognize its security value. A lock file is essentially a snapshot of your entire dependency tree at a specific moment in time, including:
- Exact version numbers for every package
- Cryptographic hashes (integrity checksums) for each package
- The complete dependency graph
When you commit your lock file to version control, you're creating a "known good" baseline. npm will verify that downloaded packages match these hashes, preventing tampering. If someone compromises a package on the registry after you've locked it, the hash won't match, and the installation will fail.
This protection only works if you use your lock file correctly:
The npm ci command (short for "clean install") is designed for automated environments like CI/CD pipelines, testing platforms, and production deployments where you need a deterministic, repeatable install of dependencies. It installs exactly what's in your lock file. It's faster than npm install and provides better security guarantees. So next to using it in your CI/CD pipelines, encourage your team to use it locally.
Your second line of defense: --ignore-scripts
While lock files protect against unauthorized changes to packages, they don't protect against malicious code that was already present when you locked the version. This is where --ignore-scripts comes in.
The --ignore-scripts flag prevents npm from executing any lifecycle scripts during installation:
npm ci --ignore-scripts
This simple flag dramatically reduces your attack surface. Malicious code that depends on running during installation simply won't execute. You've essentially neutered a primary attack vector.
You can also set this as a default in your .npmrc file:
ignore-scripts=true
With this configuration, scripts are disabled by default for all npm operations in your project.
Whitelisting
Before you rush to add --ignore-scripts everywhere, understand the trade-offs. Some legitimate packages need to run scripts during installation:
- Native modules that need compilation (packages using node-gyp)
- Build tools that need to download platform-specific binaries
- Packages with platform-specific setup requirements
For packages that legitimately need scripts, you can selectively enable them:
npm install puppeteer --ignore-scripts=false
While manually managing --ignore-scripts works, it can become cumbersome as your project grows. LavaMoat's @lavamoat/allow-scripts tool provides an automated way to manage lifecycle script allowlists, making it easier to maintain security at scale.
LavaMoat is a suite of security tools developed by MetaMask's security team to protect against supply chain attacks. The allow-scripts package specifically handles lifecycle script management by:
- Automatically disabling all install scripts by default
- Providing a declarative allowlist in your
package.json - Failing fast if unexpected scripts try to run
- Maintaining an audit trail of which packages need scripts
To use it, first setup the package
npm install -D @lavamoat/allow-scripts
Now you can intialize it for your project:
npm exec allow-scripts setup
This setup command adds ignore-scripts=true to your .npmrc (or enableScripts: false for Yarn), and installs @lavamoat/preinstall-always-fail as a dev dependency. This failsafe package throws an error if any lifecycle script attempts to run, catching configuration mistakes early.
Next step is to auto-generate your allowlist:
npm exec allow-scripts auto
This scans your dependencies and creates a configuration in your package.json:
By default, everything is set to false. You'll need to review and enable scripts for packages that legitimately need them.
Now you can run your allowed scripts with:
npm exec allow-scripts
This executes only the scripts you've explicitly allowed. If a new dependency tries to run a script that isn't in your allowlist, the installation fails with a helpful error message.
To summarize
No single security measure is perfect, but combining lock files with --ignore-scripts creates meaningful defense in depth:
- Lock files ensure you're installing known, verified versions
- --ignore-scripts prevents malicious code from executing automatically
Safe coding!
