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

By , Senior Full-Stack Engineer10 min read

Many build pipelines still use npm install to build frontend assets during deployment. Given the recent compromise of over 180 npm packages in September 2025 - where attackers used a worm-like mechanism to inject credential-stealing malware across the npm ecosystem - this represents a critical security gap that's surprisingly common in production environments.

The problem? Every time npm install runs, it resolves dependencies within semver ranges, potentially pulling in newer versions that could contain malicious code. One compromised dependency update during a production build, and attackers could inject backdoors, steal credentials, or compromise your entire deployment.

The primary fix is remarkably simple: switch to npm ci and commit your package-lock.json files. This enforces exact dependency versions and provides an immutable audit trail. For teams using pnpm, there's also a powerful new feature called minimumReleaseAge that adds a time-based safety buffer, shifting the default from blind trust to cautious adoption. Here's why every DevOps engineer should make these changes today.

The Hidden Danger in npm install

When you run npm install in your CI/CD pipeline, npm resolves package versions based on the semver ranges specified in package.json. Consider this common scenario:

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

Each time your build runs, npm will:

  1. Query the npm registry for the latest versions matching your semver ranges
  2. Download and install those versions (which may differ from your last build)
  3. Recursively resolve and install transitive dependencies
  4. Update your package-lock.json file (if running locally)

The attack vector is clear: if any dependency in your tree gets compromised and publishes a malicious update within your semver range, your next build will automatically pull it in. You won't know until it's too late.

Real-World Supply Chain Attacks

npm supply chain attacks aren't theoretical - they're happening regularly and becoming more sophisticated:

The September 2025 Worm Attack (180+ Packages)

In September 2025, attackers compromised over 180 npm packages using a self-replicating worm-like mechanism. The malicious code injected a bundle.js file that downloaded and ran TruffleHog credential scanner on developer machines, exfiltrating GitHub tokens, npm tokens, AWS access keys, and other secrets to external webhooks. The attack was capable of creating public copies of private repositories and spreading automatically through package dependencies.

Why This Attack Was So Dangerous

The September 2025 attack represents a new level of sophistication:

  • Self-replicating: The malware could spread automatically through the npm ecosystem
  • Build system targeting: Specifically designed to compromise CI/CD environments
  • Credential theft at scale: Harvested secrets from hundreds of development environments
  • Repository exfiltration: Could create public copies of private codebases
  • Cross-platform: Targeted both Windows and Linux build servers

The Common Thread

Like earlier attacks, this succeeded because build systems were using npm install without locked dependencies. Pipelines automatically pulled in malicious updates within acceptable semver ranges, giving attackers a direct path to production infrastructure.

Why npm ci Prevents These Attacks

The npm ci command (short for "clean install") was specifically designed for CI/CD environments and provides critical security benefits:

1. Enforces Exact Versions

npm ci installs exactly the versions specified in package-lock.json, ignoring semver ranges in package.json. If a malicious package update is published, your builds won't pull it in unless someone explicitly updates the lock file.

2. Detects Tampering

If package.json and package-lock.json don't match (a potential indicator of tampering), npm ci immediately fails with an error instead of silently resolving versions.

npm ERR! `npm ci` can only install packages when your package.json and package-lock.json are in sync.
npm ERR! Please update your lock file with `npm install` before continuing.

3. Provides an Audit Trail

With package-lock.json committed to version control, you have a complete audit trail of every dependency version and its cryptographic integrity hash. Security teams can review exactly what's being deployed.

4. Faster and More Reliable

As a bonus, npm ci is 2-10x faster than npm install because it skips dependency resolution and validation steps. It also automatically removes existing node_modules before installing, ensuring clean, reproducible builds.

Step-by-Step Implementation Guide

Follow these steps to harden your build pipeline:

Step 1: Generate Lock Files

If you don't already have package-lock.json files committed, generate them:

# Navigate to each project directory
cd your-project-directory

# Generate or update package-lock.json
npm install

# Verify lock file was created
ls -la package-lock.json

Step 2: Commit Lock Files to Version Control

Remove package-lock.json from .gitignore (if present) and commit:

# Check if lock files are ignored
git check-ignore package-lock.json

# If ignored, remove from .gitignore
# Then commit all lock files
git add package-lock.json
git commit -m "chore: add package-lock.json for reproducible builds"

Step 3: Update Build Scripts

Replace all instances of npm install with npm ci in your CI/CD scripts:

# Before
npm install

# After
npm ci

Common locations to check:

  • Deployment scripts (PowerShell, Bash, Python)
  • CI/CD pipeline configurations (Azure DevOps, GitHub Actions, Jenkins)
  • Dockerfile build steps
  • Makefile targets
  • package.json scripts that trigger builds

Step 4: Update Documentation

Update your project's README and developer documentation to reflect the new workflow:

## Development Setup

```bash
# Install dependencies (exact versions from lock file)
npm ci

# Start development server
npm run dev
```

## Updating Dependencies

```bash
# Use npm install to update dependencies locally
npm install

# Commit the updated package-lock.json
git add package-lock.json
git commit -m "chore: update dependencies"
```

Step 5: Verify Your Pipeline

Test your updated build pipeline to ensure it works correctly:

# Clean node_modules to simulate CI environment
rm -rf node_modules

# Test npm ci installation
npm ci

# Run your build
npm run build

# Verify the build succeeds

Best Practices for Dependency Management

Local Development vs CI/CD

Use different commands for different contexts:

  • Local development: Use npm install to add/update packages and generate updated lock files
  • CI/CD pipelines: Always use npm ci for reproducible, secure builds
  • Code reviews: Carefully review package-lock.json changes to spot unexpected dependency updates

Dependency Update Strategy

Establish a controlled process for updating dependencies:

  1. Schedule regular dependency audits (weekly or monthly)
  2. Use npm audit to identify known vulnerabilities
  3. Update dependencies intentionally in dedicated branches
  4. Test thoroughly before merging lock file changes
  5. Document why specific versions are pinned in package.json comments

Automated Security Scanning

Add automated security checks to your pipeline:

# 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'

Advanced Defense: pnpm's minimumReleaseAge

While locked dependencies (via npm ci or pnpm install --frozen-lockfile) prevent automatic installation of compromised updates in production, they don't protect developers during local development when updating dependencies. pnpm recently introduced a powerful security feature that addresses this gap: minimumReleaseAge.

How minimumReleaseAge Works

The minimumReleaseAge setting (available in pnpm v10.16.0+) creates a time-based safety buffer by refusing to install package versions that were published too recently. This shifts the security model from blind trust (install immediately upon release) to cautious adoption (wait for the community to vet new releases).

Configuration is simple. Add this to your .npmrc file:

# .npmrc
# Require packages to be at least 24 hours old before installation
minimumReleaseAge=1440

The value is specified in minutes. Common configurations:

  • 1440 minutes (24 hours): Recommended minimum for production environments
  • 4320 minutes (3 days): Conservative approach for high-security environments
  • 10080 minutes (7 days): Maximum security, allows community discovery of issues

Why This Matters

Historical data from npm security incidents shows that most compromised packages are discovered and removed from the registry within hours of publication:

  • September 2025 worm attack: Malicious packages were identified and removed within hours by the npm security team
  • UA-Parser-JS (2021): Compromised versions removed within hours of detection
  • Event-Stream (2018): Community reported suspicious behavior within 24 hours

Real-World Implementation

Here's a complete .npmrc configuration for a security-conscious development team:

# .npmrc - Project-wide pnpm configuration

# Security: Require 24-hour minimum age for all packages
minimumReleaseAge=1440

# Optional: Allow immediate installation for trusted internal packages
# minimumReleaseAgeExclude[]=@mycompany/*

When a developer tries to install a package that's too new, pnpm will fail with a clear error:

$ pnpm add axios@latest

ERR_PNPM_MINIMUM_RELEASE_AGE  The package [email protected] was published less than 24 hours ago.
Published: 2025-10-01T10:30:00.000Z
Minimum release age: 1440 minutes (24 hours)

You can override this restriction by setting minimumReleaseAge to a lower value.

Combining Both Strategies

For maximum security, combine locked dependencies with minimumReleaseAge:

Defense Layer 1: minimumReleaseAge (Local Development)
├─ Prevents developers from installing brand-new packages
├─ Provides 24+ hour buffer for community security review
└─ Catches compromised packages before they enter your lock file

Defense Layer 2: Locked Dependencies (CI/CD)
├─ Production builds use pnpm install --frozen-lockfile
├─ Enforces exact versions from pnpm-lock.yaml
└─ Prevents automatic installation of any new versions

This creates a defense-in-depth strategy:

  1. Developer wants to update a package locally
  2. minimumReleaseAge prevents installation if version is too new
  3. After waiting period, developer installs vetted version
  4. Updated pnpm-lock.yaml is committed to version control
  5. CI/CD pipeline uses pnpm install --frozen-lockfile for reproducible builds
  6. Production receives only versions that passed both time-based and explicit review

Trade-offs and Considerations

While minimumReleaseAge significantly improves security, be aware of these considerations:

Pros:
  • Automatic protection against zero-day compromised packages
  • No changes to workflow - just configure once in .npmrc
  • Works for all dependencies, including transitive ones
  • Can exclude trusted packages if needed
Cons:
  • Delays access to critical security patches (mitigated by using specific version overrides when needed)
  • Only available in pnpm (not in npm or Yarn)
  • Requires team to adopt pnpm if not already using it
  • May require exceptions for internal packages or time-sensitive updates

Migration Guide for pnpm Users

If you're already using pnpm, adding minimumReleaseAge is straightforward:

  1. Create or update .npmrc in your project root
  2. Add minimumReleaseAge=1440 (or your preferred value)
  3. Commit .npmrc to version control
  4. Communicate the change to your team
  5. Update documentation to reflect the new security policy

Your CI/CD pipeline should already be using pnpm install --frozen-lockfile, which will continue to work unchanged.

Common Questions and Concerns

Won't this prevent me from getting security updates?

This is a feature, not a bug. You want to control when updates are applied. Security updates should be:

  1. Identified through npm audit or security advisories
  2. Applied intentionally via npm update or manual package.json changes
  3. Tested in a development environment
  4. Reviewed in pull requests
  5. Deployed on your schedule, not automatically during production builds

What if package-lock.json is out of sync?

npm ci will fail immediately with a clear error message. This is good - it prevents builds from succeeding when there's potential tampering or configuration drift. Fix the sync issue locally with npm install, review the changes, and commit the updated lock file.

Does this work with Yarn or pnpm?

Yes! Other package managers have equivalent commands:

  • Yarn: yarn install --frozen-lockfile (uses yarn.lock)
  • pnpm: pnpm install --frozen-lockfile (uses pnpm-lock.yaml)
  • Yarn 2+: yarn install --immutable

The principle is the same: commit your lock file and use the immutable install command in CI/CD.

Secure Your Pipeline Today

Supply chain attacks are one of the most dangerous threats facing modern development teams. The good news? This vulnerability is trivially easy to fix.

By switching from npm install to npm ci in your CI/CD pipeline and committing your package-lock.json files, you eliminate a major attack vector while also improving build reproducibility, reliability, and speed.

This is a critical security control that should be standard in every modern build pipeline. Take 15 minutes today to audit your deployment scripts and make the change. Your future self (and your security team) will thank you.

Contact

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

Required fields are marked “(required)”.