
Hardening npm Install: Practical Script Isolation and Provenance Validation with NPM 12
Why npm install is a security boundary, not just a build step
I treat npm install as code execution, because that is exactly what it can become.
This walkthrough shows how npm 12 changes script execution and provenance validation during install, and how to use those controls to reduce JavaScript supply-chain risk in real projects. The practical goal is simple: make installs safer without breaking every build.
The key point is not that dependencies are risky in some abstract sense. The key point is that package installation is an execution boundary. If you let lifecycle scripts run with your normal developer shell or CI identity, you are trusting every transitive package author and every upstream publish event that shaped your lockfile.
How lifecycle scripts turn dependency installation into code execution
npm packages can run scripts at several points during install and publish flows. The obvious ones are preinstall, install, postinstall, and prepare, but the practical effect is the same: package metadata can trigger commands automatically.
A minimal example looks harmless until you remember it runs during install:
{
"name": "demo-scripted-package",
"version": "1.0.0",
"scripts": {
"postinstall": "node ./scripts/write-marker.js"
}
}
// scripts/write-marker.js
const fs = require("fs");
const path = require("path");
const marker = path.join(__dirname, "..", "postinstall-ran.txt");
fs.writeFileSync(marker, "postinstall ran during install\n");
console.log("wrote marker");
That example only writes a file. A malicious package can do the same thing with network calls, token reads, source tree edits, or build artifact tampering. The issue is not the command itself. The issue is that install-time code runs before most teams have any real validation around what was unpacked.
I usually think about it like this:
- fetching a package is supply-chain intake
- unpacking a package is untrusted file placement
- running lifecycle scripts is execution
Those are different trust levels, and the default workflow often compresses them into one action.
Where supply-chain compromise shows up in real JavaScript projects
In JavaScript projects, compromise tends to surface in a few repeatable ways:
- typosquatted packages that resemble a real dependency
- maintainer account takeover followed by a poisoned publish
- transitive dependency compromise, where your direct package is clean but a nested package is not
- dependency confusion, where an internal name resolves to the wrong registry
- malicious
postinstallorpreparescripts that run before your app code ever starts
The blast radius is bigger than many teams expect because install-time code often runs with:
- developer workstation credentials
- CI environment variables
- access to source trees and monorepo workspaces
- write access to lockfiles and generated assets
So the impact is not just “a bad package got installed.” It can be build poisoning, token theft, hidden backdoors in generated code, and compromised release artifacts that look perfectly normal in review.
What npm 12 changes in script execution and dependency trust
The public note around npm 12 is about reshaping how scripts and dependency trust work so supply-chain abuse is harder. I would not call that a magic switch. It is better to think of it as a chance to split three decisions that used to blur together:
- should we download this package?
- should we unpack it?
- should we execute its install-time code?
That split matters because each step deserves its own policy.
Script isolation as a way to shrink the blast radius
Script isolation is useful when you want the package contents present, but you do not want arbitrary install-time commands to inherit the full ambient environment.
In practice, isolation should reduce what lifecycle scripts can see or change:
- limit environment variables
- avoid exposing long-lived secrets
- run in a controlled working directory
- make writes explicit instead of implicit
- keep script execution out of the same job that handles sensitive release credentials
That does not mean all scripts are bad. Native addons, code generators, and some platform-specific packages genuinely need install-time work. The goal is to make that work intentional, contained, and observable.
A good rule is: if a dependency needs to run code during install, it should do so in a context you are willing to audit like any other build step.
Provenance validation as a gate before dependencies are trusted
Provenance is the other half of the story.
Script isolation limits what a package can do after it arrives. Provenance validation asks a different question: should we trust that this tarball came from the publisher and workflow we expected?
When provenance is available, I want to check a few things:
- who published the package
- whether the publish is tied to a known automation identity or verified workflow
- whether the package metadata matches the expected source
- whether the tarball hash matches what the registry reports
- whether the package was published recently and intentionally, not as an unexpected change
If provenance is missing, weak, or inconsistent, I treat that as a reason to slow down or fail closed. That matters most for packages used in production builds, release pipelines, or developer bootstrap paths.
Build a small lab that shows the risk clearly
The easiest way to understand install-time risk is to build a small lab with two local packages:
- one package that uses a lifecycle script
- one package that has no lifecycle scripts
You do not need a malicious payload. A marker file is enough to prove that install-time code is real code execution.
Pick a package with lifecycle scripts and a second package with no scripts
Create this structure:
lab/
app/
scripted-package/
clean-package/
The scripted package:
{
"name": "scripted-package",
"version": "1.0.0",
"scripts": {
"postinstall": "node ./scripts/postinstall.js"
}
}
// scripted-package/scripts/postinstall.js
const fs = require("fs");
const path = require("path");
const out = path.join(__dirname, "..", "install-marker.txt");
fs.writeFileSync(out, "installed with postinstall\n");
console.log("scripted-package postinstall executed");
The clean package:
{
"name": "clean-package",
"version": "1.0.0"
}
Now point the app at both packages using local file references:
{
"name": "install-lab-app",
"private": true,
"dependencies": {
"scripted-package": "file:../scripted-package",
"clean-package": "file:../clean-package"
}
}
This setup is intentionally plain. That is the point. The difference between the two packages shows up at install time, before your application code runs.
Capture a baseline install trace, lockfile state, and build output
Run three install modes in separate clean directories so the results are easy to compare.
First, the normal install:
npm ci
Then the script-isolated version:
npm ci --ignore-scripts
Then the same tree in a CI-like environment where you record logs and artifact diffs.
What I look for:
- whether the marker file appears
- whether any generated files appear in the package tree
- whether the lockfile changes
- whether the install logs mention lifecycle script execution
- whether the install exits cleanly even when scripts are skipped
A simple comparison table keeps the exercise grounded:
| Mode | Lifecycle scripts | Expected marker file | Risk level |
|---|---|---|---|
| normal install | yes | present | higher |
| script-isolated install | no | absent | lower |
| provenance-gated install | only after trust check | absent unless approved | lowest |
This lab is not about proving every script is dangerous. It is about showing that install-time behavior is part of your threat model whether you acknowledge it or not.
Walk through the install pipeline step by step
preinstall, install, postinstall, prepare, and how they differ
The lifecycle hooks matter because they run at different times and for different reasons:
preinstall: runs before package installation logicinstall: often used for package-specific setuppostinstall: runs after install and is the one most people think aboutprepare: runs in more cases than many people expect, especially around git-based installs and package preparation
The subtle trap is that teams often audit only postinstall. That misses packages that hide work in prepare, or packages that depend on a transitive script in a nested dependency tree.
A few practical observations:
preinstallis a good place for a package author to fail fastpostinstallis often used for build steps, native compilation, and generated filespreparecan run during workflows that do not look like a normal package install- scripts can be inherited transitively, so the package you import is not the only one that matters
If you are reviewing a lockfile, you are not done until you know which packages can execute hooks and why.
Which steps run on developer machines, in CI, and during publish flows
The execution context changes the risk.
On a developer workstation, lifecycle scripts can reach:
- SSH agents
- browser profiles
- local cloud credentials
- repo checkout state
- private workspace files
In CI, the risk often shifts toward:
- release tokens
- artifact signing keys
- deployment credentials
- caches shared across branches
- hidden trust in upstream build output
During publish flows, the risk includes generated artifacts that are about to be shipped to end users. That is where a compromised install can turn into a compromised release.
I usually map lifecycle execution like this:
| Context | What scripts can touch | What to avoid |
|---|---|---|
| developer machine | local files, user env, SSH, cached tokens | secrets in shell env during install |
| CI build job | repo, artifacts, ephemeral secrets | running untrusted installs in release jobs |
| publish job | package contents, release credentials | any dependency install that is not pinned and verified |
The safer pattern is to separate dependency intake from release assembly. Do not let the same job both trust new packages and sign artifacts.
Isolate scripts during install without breaking the whole workflow
Safer install modes and when to use them
The core idea is simple: install packages first, execute scripts only when you have a reason to trust them.
A practical rollout looks like this:
- run a script-isolated install in the default path
- identify which packages actually need install-time code
- allowlist only the packages that must build native addons or generated assets
- run those build steps in a separate controlled stage
- keep the rest of the tree script-free
For many projects, --ignore-scripts is the easiest first step because it shows you exactly which parts of the tree depended on lifecycle execution. That gives you a real inventory instead of guesses.
The downside is obvious: some packages will break. That is useful information, not a failure. It tells you where the build assumes untrusted code should run automatically.
A good control plane for installs looks like this:
- default to no scripts
- explicitly approve exceptions
- run approved build steps in a restricted job
- log which package requested the exception and why
Handling packages that rely on native compilation or generated assets
Some packages genuinely need install-time work.
Common examples include:
- native Node addons
- packages that ship prebuilt binaries for different platforms
- packages that generate code from schemas at install time
- packages that compile assets as part of release packaging
I would not block those blindly. I would isolate them.
The safer pattern is:
- verify the package source and provenance first
- run the build in a container or disposable worker
- keep secrets out of the environment
- persist only the generated artifact, not the full install context
- pin the package version so the behavior stays reproducible
If a package cannot work without a script, that is not automatically a security flaw. It is a reason to move the script into a place where it can be observed and controlled.
Validate provenance before a package is allowed into the build
What provenance signals to inspect in the registry and package metadata
Provenance is not one field. It is a set of signals that should line up.
When I review a package, I want to know:
- the exact version being installed
- the registry origin
- the tarball hash
- whether the package has a publish attestation
- whether the publisher identity matches the expected maintainer or automation flow
- whether the publish came from a known source repository
If your tooling surfaces provenance metadata, do not just display it. Make it part of the admission decision.
A simple policy mindset helps:
- known-good provenance: proceed
- missing provenance on a low-risk internal tool: maybe proceed with review
- missing or inconsistent provenance on a sensitive dependency: fail closed
The point is not to make every install perfect. The point is to prevent unreviewed trust expansion.
How to fail closed when provenance is missing, weak, or inconsistent
A good failure policy spells out what happens next.
For example:
- if a package has no provenance and it is in the release path, block the build
- if a package changed publisher identity unexpectedly, require manual review
- if the tarball hash or metadata does not match the lockfile expectation, stop immediately
- if a dependency switches from no scripts to script execution, treat that as a security change
This is where teams often get too loose. They see an install warning and assume it is cosmetic. It is not cosmetic if the package is now asking to run code in your CI job.
A short operational checklist works well:
- document which packages require provenance
- define the exception process
- make the exception expire
- log the reason for override
- review the exception list regularly
Compare the same dependency tree under different trust policies
Normal install versus script-isolated install versus provenance-gated install
The same tree behaves very differently under different policies.
| Policy | What happens | What you learn |
|---|---|---|
| normal install | packages unpack and scripts run | which scripts were hiding in the tree |
| script-isolated install | packages unpack, scripts do not run | whether the tree depends on install-time execution |
| provenance-gated install | packages are accepted only if trust signals match | whether the tree comes from expected publishers and workflows |
The normal install is useful for compatibility testing. The isolated install is useful for risk discovery. The provenance-gated install is useful for release quality control.
I would not pick one forever. I would layer them.
- use normal installs for local development when necessary
- use script-isolated installs in CI by default
- require provenance gates in release and deployment jobs
What to look for in logs, exit codes, and artifact differences
The artifacts tell you whether your control is real.
Look for:
- unexpected new files under
node_modules - generated binaries or assets that appear only when scripts run
- install logs that mention lifecycle hooks
- exit code differences between normal and isolated installs
- lockfile changes after install, especially in repositories that should be deterministic
I also diff the tree after install, because scripts often leave fingerprints:
find node_modules -maxdepth 3 -type f | sort > after.txt
git diff --no-index -- before.txt after.txt
If the diff only appears under normal install, you have proof that install-time code changed the filesystem. That is the behavior you are trying to constrain.
Apply the controls in a real project and CI pipeline
Monorepo-specific exceptions, allowlists, and workspace boundaries
Monorepos make this trickier because a single install can touch many packages.
The safe approach is to define boundaries:
- root workspace install policy
- per-package exception list
- generated code directories that are allowed to change
- release packages that are more tightly controlled than internal tooling
Avoid broad allowlists like “all scripts in this repo are fine.” That is how one risky package gets normalized because three others legitimately need build steps.
Instead, keep the allowlist narrow:
- package name
- version range
- script type
- reason for approval
- review owner
That gives you a paper trail when behavior changes.
Pinning npm versions and keeping behavior consistent across environments
If npm 12 is the policy layer you are adopting, pin it.
Different npm versions can differ in:
- script execution behavior
- metadata interpretation
- registry trust handling
- lockfile generation
- error handling on install failures
If developers run one version and CI runs another, your policy becomes nondeterministic. That is a bad place to be when you are trying to reduce supply-chain risk.
A good baseline is:
- pin the npm version in CI
- document the supported local version
- enforce it in the repo bootstrap instructions
- fail if the runtime version drifts too far
Consistency matters more than novelty here.
Edge cases that can create false confidence
Optional dependencies, platform-specific packages, and hidden script paths
Some of the ugliest surprises come from packages you do not think about.
Optional dependencies can still run scripts on the platforms where they are installed. Platform-specific packages may only activate on Linux, macOS, or Windows, which means your local machine can look clean while CI does something different.
Watch for:
- optional native addons
- OS-specific binary fetchers
- conditional
prepareorpostinstallbehavior - scripts hidden in transitive dependencies
If you only test on one platform, you are not testing the whole install surface.
Lockfile drift, vendored code, and generated files that bypass review
Lockfiles help, but they do not remove trust problems.
Three common blind spots:
- lockfile drift: the lockfile changes without a matching review of install-time behavior
- vendored code: generated or copied files are committed and no longer obviously tied to the original package
- generated assets: scripts create files that are later treated as source of truth
That last one matters a lot. A package can run a script, generate a file, and then your repo quietly treats that file as if it were reviewed source code. Once that happens, install-time risk has already crossed the boundary.
The defense is to review the generated artifact path, not just the package manifest.
Defense-in-depth beyond npm 12
Dependency review, package pinning, and signature or provenance checks at multiple layers
npm 12 can improve the install story, but I would not stop there.
A solid stack usually includes:
- direct dependency review before adoption
- exact or tightly pinned versions for sensitive packages
- provenance or signature checks where available
- separate install and publish jobs
- restricted secrets in install environments
- code review for generated assets and lockfile updates
Think of it as multiple gates, not one perfect gate.
A single control can fail open. Multiple weaker controls are much harder to bypass together.
Monitoring for new postinstall behavior and unexpected install-time diffs
One of the most useful defenses is boring monitoring.
Track:
- new lifecycle scripts in dependency updates
- package version bumps that introduce build steps
- install-time file diffs
- changes in package publisher identity
- CI jobs that suddenly need more permissions after a dependency update
If a dependency update adds a postinstall, I want to know before it lands in production. That is a real security event, not just a routine dependency refresh.
Practical rollout checklist
Audit the current dependency tree and classify risky packages
Start with inventory.
- list direct and transitive dependencies
- identify packages with lifecycle scripts
- identify packages that generate binaries or assets
- mark packages used in release or deployment jobs
- classify which packages require install-time execution and why
You do not need perfect classification on day one. You need a baseline.
Document exceptions, test rollback paths, and measure developer impact
Controls fail when they are too annoying to keep.
Before enforcing script isolation or provenance gating broadly:
- document the exception process
- test a rollback path for broken installs
- measure which packages actually need scripts
- tell developers how to request an override
- keep the policy visible in CI logs
If the rollout blocks every other build, people will disable it. If it only blocks truly risky cases, it can stick.
Conclusion: reduce supply-chain risk without making installs unusable
The useful lesson in the npm 12 discussion is not that package installs were safe before and unsafe now. They were always a trust boundary. npm 12 just makes that boundary harder to ignore.
My default stance is simple:
- isolate scripts unless you have a reason not to
- validate provenance before you trust new packages
- keep install behavior consistent across local and CI environments
- allow exceptions only when you can explain them
That keeps dependency installs usable, but no longer silent. And for JavaScript supply-chain defense, silent is the dangerous mode.


