Stop Supply Chain Attacks: Why Your Build Pipeline Should Use Locked Dependencies

By , Senior Full-Stack EngineerUpdated 7 min read

A lot of build pipelines still run npm install to build frontend assets during deployment. In September 2025, attackers compromised over 180 npm packages with a self-spreading worm that injected credential-stealing code. A pipeline that installs whatever the registry serves at build time is exactly where that kind of attack lands.

Three changes close most of that window:

  1. Commit your lockfile.
  2. Install from it with npm ci (or your package manager's equivalent) everywhere builds run.
  3. Add a release-age cooldown, so brand-new versions can't be installed at all, even when you update dependencies locally.

Here's why each one works, and how to set it up.

What npm install Actually Does in CI

Take a typical package.json:

{
  "dependencies": {
    "lodash": "^4.17.0",
    "axios": "~1.6.0",
    "express": "*"
  }
}

npm install treats the lockfile as a starting point, not a rule. With a lockfile that matches package.json, it installs the locked versions. But if the lockfile is missing (or in .gitignore), or package.json has changed, npm resolves fresh versions inside those ranges, installs them along with their transitive dependencies, and rewrites the lockfile.

That's the opening. If any package anywhere in your tree publishes a malicious version inside your ranges, the next build that resolves fresh pulls it in and runs its install scripts on your build server.

The September 2025 Worm

The September 2025 attack showed how fast this moves. The malicious code ran TruffleHog, a credential scanner, on whatever machine installed it, then sent GitHub tokens, npm tokens, AWS keys, and other secrets to attacker-controlled webhooks. It could publish public copies of private repositories, and it spread by using stolen npm tokens to publish infected versions of other packages.

The pipelines that got hit had the same thing in common: they let the registry decide what to install at build time.

Why npm ci Closes the Gap

npm ci (clean install) was built for automated environments, and it behaves differently in the ways that matter:

  • It installs exactly what the lockfile says. Version ranges in package.json don't come into it. A malicious version published after your last lockfile update can't get in until someone deliberately updates the lockfile.

  • It fails when package.json and the lockfile disagree. No silent re-resolution:

    npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync.
  • It verifies integrity. The lockfile records a hash for every package, so a tampered tarball fails the install.

  • It starts clean. It deletes node_modules first, so builds are reproducible. Skipping dependency resolution usually makes it faster than npm install, too.

Setting It Up

1. Generate and commit the lockfile. If package-lock.json is in .gitignore, take it out, run npm install once locally, and commit the result:

git check-ignore package-lock.json   # prints the path if it's ignored
npm install
git add package-lock.json
git commit -m "chore: commit lockfile for reproducible builds"

2. Replace npm install with npm ci everywhere builds run. Check deployment scripts, CI pipeline definitions (GitHub Actions, Azure DevOps, Jenkins), Dockerfiles, and Makefiles.

3. Add an audit step that fails the build on serious findings:

# Azure DevOps example
steps:
  - script: npm ci
    displayName: 'Install dependencies (locked)'

  - script: npm audit --omit=dev --audit-level=high
    displayName: 'Security audit (fail on high/critical)'

  - script: npm run build
    displayName: 'Build application'

4. Update the README so new developers know npm ci installs and npm install is only for deliberately changing dependencies.

Other package managers have the same switch:

  • pnpm: pnpm install --frozen-lockfile (you can make it the default with frozen-lockfile=true in .npmrc)
  • Yarn 2+: yarn install --immutable
  • Yarn 1: yarn install --frozen-lockfile

Updating Dependencies on Purpose

A locked pipeline means updates happen when you choose. That's also the honest answer to "won't this stop me from getting security fixes?" Fixes still come in, through a reviewed change instead of a surprise at build time:

  1. Find what needs updating with npm audit, Dependabot, or Renovate.
  2. Update on a branch with npm install <package>@<version> or npm update.
  3. Review the lockfile diff in the pull request. Unexpected transitive changes are worth a second look.
  4. Merge, and let CI build from the new lockfile.

Release-Age Cooldowns

A lockfile protects builds, but it doesn't protect the moment you update. If you run npm update while a malicious version is live, it goes into your lockfile and from there into every build. A cooldown closes that gap: the package manager refuses to install any version published less than a set time ago.

Compromised versions are usually caught and pulled fast. The September 2025 worm's versions were identified within hours to days, and the hijacked UA-Parser-JS releases in 2021 were live for about four hours. A cooldown longer than that window means you never see the bad version.

pnpm added the setting (minimumReleaseAge) in version 10.16, and pnpm 11 turns it on by default at 1440 minutes (24 hours). On pnpm 11 it lives in pnpm-workspace.yaml; on pnpm 10.x, put minimumReleaseAge=1440 in .npmrc instead:

# pnpm-workspace.yaml
# Refuse versions published in the last 24 hours
minimumReleaseAge: 1440

# Internal packages can skip the wait
minimumReleaseAgeExclude:
  - '@mycompany/*'

The value is in minutes. Common choices are 1440 (one day), 4320 (three days), and 10080 (a week). When a version is too new, pnpm doesn't fail the install. It quietly picks the newest version that satisfies both your range and the age rule. If you'd rather get a hard failure when someone explicitly asks for a too-new version, pnpm 11 adds minimumReleaseAgeStrict.

The other package managers now have equivalents, with different names and units:

  • npm 11.10+: min-release-age (days)
  • Yarn 4.10+: npmMinimalAgeGate (minutes)
  • Bun 1.3+: minimumReleaseAge (seconds)

The tradeoff is that a genuine security patch also waits out the cooldown. When you need one immediately, add that package to the exclude list, install the specific version, then remove the exclusion. Don't lower the global setting. The lockfile keeps CI on the version you chose either way.

A cooldown isn't a guarantee. In 2018, event-stream's malicious dependency went unnoticed for about two months, which no reasonable cooldown would have covered. It's one layer, and it works best alongside the others.

How the Layers Fit Together

Layer 1: Release-age cooldown (any install, including local updates)
├─ Brand-new versions can't be installed at all
└─ Malicious releases are usually pulled before the cooldown ends

Layer 2: Locked installs (CI and production builds)
├─ npm ci / pnpm install --frozen-lockfile
├─ Exact versions and integrity hashes from the lockfile
└─ Nothing changes without a reviewed lockfile update

A developer updates a package, the cooldown keeps out anything too fresh, the lockfile change goes through review, and CI installs exactly what was reviewed. Production only ever gets versions that cleared both checks.

If you're on pnpm, there's a third layer worth adding: an allow list for install scripts (onlyBuiltDependencies), so a malicious postinstall script never runs even if a bad version does get onto disk. The Mini Shai-Hulud write-up shows all three working together.

Start With the Lockfile

If you do one thing, commit your lockfile and switch CI to npm ci. It takes a few minutes per project, and it means your build pipeline stops deciding which versions to trust on its own. Then add the cooldown, and the window most npm worms depend on is closed on both sides.

Contact

Drop me a line. I read everything and reply within a day.

Required fields are marked “(required)”.