dependency pinning npm

Dependency Pinning for npm: Defending Against Supply Chain Attacks

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 managerConfig ParameterUnitExample (7 days)
npmmin-release-ageDaysmin-release-age=7
pnpmminimumReleaseAgeMinutesminimumReleaseAge=10080
BunminimumReleaseAgeSecondsminimumReleaseAge=604800
YarnnpmMinimalAgeGateDaysnpmMinimalAgeGate=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 ci in all CI/CD pipelines and deployments, and avoid a direct npm install
  • Commit and review package-lock.json as seriously as any other source file
  • Use overrides in package.json to 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 disabling ignore-scripts globally
  • Run npm audit regularly, 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.