Over the last several months, supply chain attacks against open-source libraries have become one of the most consistent threats facing development teams and businesses that vendor software.
The Axios npm package compromise was only live for a matter of hours, but left widespread exposure. The most recent attacks targeted over 170 npm packages and 2 PyPi packages – ranging from @tanstack, @mistralai, and many more.
Rather than revisiting the details of specific incidents, this article focuses on the practical steps you can take to defend against this type of attack, through dependency pinning and a few additional hardening practices.
How npm versioning works
When you install packages through npm you’ll store these packages and their installed versions in a package.json file. The entries here are your direct dependencies, and may look like this:
"dependencies": {
"Package1": "1.2.3",
"Package2": "^1.2.3",
"Package3": "~1.2.3"
}
There are three types of dependency versions shown in the code above:
- Versions prefixed with
~indicate that patch updates are allowed to be installed (e.g. 1.2.3, 1.2.4, or anything below 1.3.0) - Versions prefixed with
^allow both patch and minor versions to be installed (e.g. 1.2.2 > 1.3.0) - Version numbers without a prefix mean that minor or patch versions won’t be installed, only the exact version listed
The package lock file
With npm, after installing a package, you’ll generate a package-lock.json file. This file includes the full resolved dependency tree, which also includes the transitive dependencies (your dependencies of your direct dependencies).
Each of the entries in this file will have a hash of the package, the path to where it was resolved from, and then its listed dependencies.
This file should always be committed with the rest of the repository. It’s what allows the builds to be reproducible and is the basis of securing the project dependencies.
Pinning direct dependencies
Start by removing the ^ and ~ prefixes from the package.json file manually, or reinstall packages directly with the following to store the exact versions of the dependencies that you’re using:
npm install --save-exact [packageName@version]
You can make this a default configuration for all future installs, either individually for yourself or globally within a repository config file:
npm config set save-exact true
Or commit a .npmrc file to your repository so it applies to the whole team:
save-exact=true
Regenerate the package lock file
After pinning, regenerate your lock file without touching node_modules:
npm i --package-lock-only
Or do a clean reinstall after deleting the package-lock.json file and the node_modules directory:
npm install
Enforcing configuration with .npmrc
As mentioned above, the .npmrc file within your repository ensures that the npm configuration is consistent across each developer that interacts with the project and the CI environment, without having to manually include the npm input arguments each time.
Without this file, a developer’s individual npm config or omission of the various arguments could accidentally break the dependency pinning or introduce potential project risk. For example, if a developer installs a new package without the --save-exact argument this would include the semver range.
As a general recommendation, consider the following baseline .npmrc file:
save-exact=true
ignore-scripts=true
This enforces exact versioning and blocks install scripts for everyone working on the project, by default, without relying on individuals remembering to pass the right flags. Any exceptions to this, such as packages that require scripts to build, can then be handled explicitly via npm rebuild as covered below.
Overriding transitive dependencies
Sometimes a package that you depend on can pull in a vulnerable version of something that you don’t have direct control over. The overrides field in package.json lets you force a specific version anywhere in the tree:
"overrides": {
"axios": "1.14.0"
}
This is one method of how you can remediate a compromised transitive dependency without waiting for an upstream maintainer to issue a new release. Yarn uses resolutions, whilst pnpm uses overrides with slightly different behaviour.
Deploying exact dependencies with npm
Always use npm ci in CI/CD pipelines and deployments. This ensures that the exact versions that are defined in the lock file are installed, and removed anything that shouldn’t be there.
npm ci
Unlike npm install, the npm ci command installs strictly from the lock file and will hard fail if package.json and package-lock.json are out of sync.
Blocking install scripts
The majority of recent supply chain attacks have been executed through the pre, post, or install scripts, where the code defined in one of these scripts is actioned within seconds of the npm install completing. The --ignore-scripts flag prevents this by preventing any of the install scripts:
npm install --ignore-scripts
npm ci --ignore-scripts
Why this breaks some packages
Care should be taken when using this argument as some native packages (e.g. node-gyp compiled modules) require the install scripts to build. Without their install scripts running, these packages install but fail silently or throw errors at runtime, with nothing in the install output to tell you why.
Common packages that fall into this category include bcrypt, sharp, canvas, sqlite3, fsevents, and any package wrapping a native system library. If your project uses any of these, you’ll notice breakage after enabling ignore-scripts.
Identifying which packages need scripts
Rather than discovering broken packages at runtime, you can audit proactively by inspecting package-lock.json, which flags every package in the tree that has a lifecycle script. If you have jq available, this gives you a clean list:
jq '.packages | to_entries[] | select(.value.hasInstallScript == true) | .key' package-lock.json
Which outputs something like:
"node_modules/bcrypt"
"node_modules/sharp"
Selectively rebuilding trusted packages
Our recommendation here is to install all packages without scripts, audit any packages that break functionality, and then rebuild the individual packages that are trusted and require scripts.
npm install --ignore-scripts [email protected]
npm rebuild examplePackage
Rebuilding a package re-runs the build for the names package without causing the scripts to run for every other dependency. This is a common method for allowing trusted packages to use their defined scripts.
Alternatively, through pnmp you can define the individual packages that should be whitelisted. By default pnmp doesn’t allow lifecycle scripts to run, which is a direction npm should consider taking.
"pnpm": {
"onlyBuiltDependencies": ["examplePackage1", "examplePackage2"]
}
Updating npm packages with minimum release age
When it comes time to update packages from the pinned versions, the latest version of npm has implemented the --min-release-age parameter. This new input now aligns with other package managers like pnpm by enforcing a minimum lifetime of a package before it can be installed.
npm install --min-release-age 7
In this example the updates without pinned versions would only apply if a new version had been published for at least 7 days. The malicious Axios versions were only live for a few hours before being removed from the npm registry. A delay of just one day would have prevented exposure to this.
Setting a persistent minimum release age
Rather than passing the flag each time, you can add it to your .npmrc file to enforce the policy across your whole team and CI environment, consistent with the other configuration covered above:
min-release-age=7
# Additional configuration
Minimum release age across package managers
All major package managers now support timegating of package release age, but they can differ in both naming convention and units:
| Package manager | Config Parameter | Unit | Example (7 days) |
|---|---|---|---|
| npm | min-release-age | Days | min-release-age=7 |
| pnpm | minimumReleaseAge | Minutes | minimumReleaseAge=10080 |
| Bun | minimumReleaseAge | Seconds | minimumReleaseAge=604800 |
| Yarn | npmMinimalAgeGate | Days | npmMinimalAgeGate=7 |
Bypassing the minimum release age delay when needed
If there’s a requirement to override the minimum release age for a package install or update, such as a vulnerability patch or hotfix, you can bypass the delay on a per-install basis:
npm install --min-release-age 0 examplePackage
The limits of npm audit
Everyone should be running npm audit as part of their regular maintenance procedure, but in the case of this Axios compromise npm audit returned no vulnerabilities as the package could be considered a zero-day.
Implementing dependency pinning and performing a clean install via npm ci are the primary protection methods for malicious dependencies. The minimum release age within npm and pnpm are an extra fail-safe for any projects that may have missing transitive overrides or exact pinned versions.
Putting it all together
Dependency pinning isn’t a single setting to enable, it’s a combination of small, compounding actions that close the window a malicious package has to exploit.
The baseline .npmrc below combines everything covered in this article. Commit it to the root of your repository and it will enforce consistent behaviour across your team and CI environment without relying on individuals remembering the right flags:
save-exact=true
ignore-scripts=true
min-release-age=7
Beyond that file, the key habits to build into your workflow are:
- Use
npm ciin all CI/CD pipelines and deployments, and avoid a directnpm install - Commit and review
package-lock.jsonas seriously as any other source file - Use overrides in
package.jsonto force safe versions of vulnerable transitive dependencies while waiting on upstream maintainers - Rebuild only the specific trusted packages that require install scripts via
npm rebuild, rather than disablingignore-scriptsglobally - Run
npm auditregularly, but don’t rely on it alone.
No single measure here is a silver bullet. A lock file without npm ci can be bypassed, and npm audit won’t catch zero-days. But applied together, these practices make your dependency tree significantly harder to weaponise.

