📚 This is Level 3 of our npm defense series. Start with Level 1: The 30-Second npm Defense (individual .npmrc), then Level 2: Claude Code Hooks (agent-level enforcement). This post covers team/CI defense. Level 4: Incident Response is the final layer.

TL;DR — Your CI pipeline runs npm install dozens of times per day. After Bitwarden’s GitHub Actions got hijacked on April 22, this 5-layer playbook locks down npm in CI: enforce npm ci, validate lockfiles, review dependencies in PRs, pin Actions to SHA, and publish with OIDC. Copy-paste workflows included. Jump to Layer 1 →

📊 What the 5 layers protect:

  • Layer 1: npm ci ensures deterministic installs from lockfile (no silent resolution changes)
  • Layer 2: lockfile-lint validates package sources come from trusted registries
  • Layer 3: Dependency review action flags new deps in PRs before merge
  • Layer 4: SHA pinning prevents compromised Actions tags from injecting malicious code
  • Layer 5: OIDC trusted publishing eliminates long-lived npm tokens from CI

Here’s a typical GitHub Actions CI workflow. Count the red flags:

# .github/workflows/ci.yml — BEFORE (5 problems)
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # ❌ Mutable tag
- uses: actions/setup-node@v4 # ❌ Mutable tag
- run: npm install # ❌ Not npm ci
- run: npm test
- run: npm publish # ❌ Uses stored NPM_TOKEN
env:
NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} # ❌ Long-lived token
# ❌ No lockfile validation
# ❌ No dependency review

On April 22, 2026, Bitwarden’s CI pipeline was hijacked. The malicious @bitwarden/cli@2026.4.0 was live for 90 minutes. The payload performed 7 independent credential collection operations, harvesting AWS, GCP, Azure, GitHub, npm, and SSH credentials from every CI runner that pulled it (Endor Labs, 2026).

The attack vector? A compromised GitHub Action in Bitwarden’s publish workflow. Not a malicious package. Not a phishing email. A CI/CD pipeline.

Here’s the 5-layer playbook that would have stopped it.


Why Is Your CI Pipeline the Biggest npm Attack Surface?

Your CI pipeline installs dependencies more often than any developer, runs with elevated permissions, and stores secrets that cascade into production. In spring 2026, attackers shifted from targeting packages to targeting CI/CD automation directly, compromising tj-actions/changed-files, trivy-action, Nx, and Bitwarden CLI (GitHub, 2026).

This is a pattern, not a coincidence. CI runners are higher-value targets than developer laptops.

SurfaceDev machineCI runner
Install frequencyA few times/dayEvery PR, every push
Secrets accessPersonal tokensDeploy keys, cloud creds, npm tokens
Review gateDev sees outputLogs scroll past unread
Blast radiusOne laptopProduction, all downstream consumers

The Bitwarden payload scraped CI runner memory for GitHub tokens, then used those tokens to inject malicious workflows into other repositories. One compromised runner cascaded into many.

Key insight: Spring 2026 saw supply chain attacks targeting tj-actions/changed-files, Nx, trivy-action, and Bitwarden CLI, establishing a clear pattern of attackers targeting CI/CD automation itself rather than application code (GitHub Actions 2026 Security Roadmap). Your CI pipeline is the highest-value target in your software supply chain.

Levels 1 and 2 of this series protect the developer’s machine. This post protects the pipeline that builds and ships your code.


Layer 1 — Why Should You Enforce npm ci Over npm install?

npm ci installs exclusively from the lockfile and fails if there’s any mismatch. npm install silently resolves different versions and rewrites the lockfile. In CI, npm install is a supply chain vulnerability because it lets new versions slide in without review. OWASP lists npm ci as a baseline CI hardening recommendation (OWASP, 2026).

Here’s the difference:

Behaviornpm installnpm ci
Reads lockfileYes, looselyYes, strictly
Rewrites lockfileYesNever
Fails on mismatchNoYes
Deletes node_modulesNoYes (clean slate)
Resolves new versionsYesNever

When we switched our CI from npm install to npm ci, we caught three silent version bumps in the first week. Dependencies that had been quietly upgrading themselves on every build were suddenly failing loud. That’s the point.

Your CI step should look like this:

- name: Install dependencies
run: npm ci --ignore-scripts

Commit a project-level .npmrc so every teammate and CI runner inherits the same defaults:

# .npmrc (committed to repo root)
ignore-scripts=true
save-exact=true
audit-level=moderate

After install, rebuild only the specific packages that need lifecycle scripts:

Terminal window
npm rebuild sharp esbuild # Only if your project uses them

Key insight: npm ci enforces strict lockfile adherence and aborts installation if inconsistencies are detected, making it the only npm install command safe for CI pipelines (OWASP NPM Security Cheat Sheet, 2026). npm install in CI is a supply chain vulnerability because it silently resolves and installs versions not present in the reviewed lockfile.

Already using npm ci? Good. But npm ci trusts whatever the lockfile says. If an attacker modifies the lockfile in a PR, npm ci will install the malicious package without question. That’s Layer 2.


Layer 2 — How Do You Validate Lockfile Integrity in CI?

Attackers inject compromised packages into lockfiles via pull requests. If a lockfile is modified to include a new dependency or change a source URL, npm ci will install it without question. lockfile-lint validates that all packages resolve to trusted registries and flags malicious modifications before install (Snyk, 2026).

Add lockfile-lint as a dev dependency:

Terminal window
npm install --save-dev --save-exact --ignore-scripts lockfile-lint

Create a config file at your repo root:

.lockfile-lintrc.json
{
"path": "package-lock.json",
"type": "npm",
"allowedHosts": ["npm"],
"allowedSchemes": ["https:"],
"allowedUrls": [],
"emptyHostname": false
}

Add the CI step, running it before npm ci:

- name: Validate lockfile
run: npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --allowed-schemes "https:"

What lockfile-lint catches:

  • Packages resolving to non-npm registries (attacker-controlled servers)
  • HTTP URLs instead of HTTPS (man-in-the-middle risk)
  • Empty hostnames in resolution URLs (a known obfuscation technique)

Key insight: npm lockfiles can be a security blindspot because npm ci trusts them completely. Malicious actors inject compromised packages into lockfiles through pull requests, and the modified lockfile fetches malicious code on every subsequent install (Snyk, 2026). lockfile-lint is the validation layer between “lockfile changed” and “packages installed.”

Get weekly npm security tips — One email per week. CI hardening, hook configs, and real attack breakdowns. Subscribe to AI Developer Weekly →


Layer 3 — How Do You Review New Dependencies Before They Merge?

GitHub’s dependency review action scans PR diffs for new or updated dependencies and blocks merge if any have known vulnerabilities. This catches compromised transitive dependencies at PR review time instead of post-deploy. In 2025, over 454,600 new malicious packages were identified across registries, a 75% year-over-year increase (Sonatype, 2026).

Create .github/workflows/dependency-review.yml:

name: Dependency Review
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/dependency-review-action@4901385134134e04cec5fbe5ddfe3b2c5bd5d976 # v4.0.0
with:
fail-on-severity: moderate
comment-summary-in-pr: always
deny-licenses: GPL-3.0, AGPL-3.0

What this catches:

  • New dependencies with known CVEs (fail the PR check)
  • License changes that violate your policy
  • Transitive dependencies that were added without explicit review

The plain-crypto-js package that delivered the axios RAT? It arrived as a transitive dependency nobody chose to install. This action would have flagged it in the PR diff before merge.

For AI-assisted development, this is the CI equivalent of the PreToolUse hook from Level 2. Your agent adds a package locally, the hook catches it. The PR hits CI, the dependency review action catches it again. Defense in depth.

Key insight: Over 454,600 new malicious packages were identified in 2025, a 75% year-over-year increase (Sonatype State of the Software Supply Chain 2026). GitHub’s dependency review action is the CI-level gate that prevents these packages from entering your codebase through unreviewed PR dependency changes.


Layer 4 — Why Must You Pin GitHub Actions to Full SHA?

Version tags like @v4 can be retagged to point to different code at any time. The tj-actions/changed-files, Nx, and Bitwarden attacks all exploited mutable tags or workflow modifications to inject malicious code into CI pipelines. Pinning to a full commit SHA ensures your workflow runs the exact code you reviewed (StepSecurity, 2026).

Before and after:

# BEFORE — tag can be repointed by attacker
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
# AFTER — immutable commit SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4

How to find the SHA for a release tag:

Terminal window
# Get the commit SHA for a specific tag
git ls-remote https://github.com/actions/checkout refs/tags/v4.1.1

Automate SHA pinning updates with Dependabot. Add .github/dependabot.yml:

version: 2
updates:
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "weekly"

Dependabot submits PRs when pinned actions have new releases, including the new SHA. You review the diff, merge, done. No manual SHA lookups.

Key insight: GitHub’s 2026 Actions security roadmap introduces workflow dependency locking, policy-driven execution controls, scoped secrets, and a native egress firewall for runners, all in public preview within 3-9 months (GitHub Blog, 2026). Until those features ship, SHA pinning with Dependabot is the strongest available defense against compromised Actions.


Layer 5 — How Do You Publish npm Packages Without Stored Tokens?

OIDC trusted publishing eliminates long-lived npm tokens from CI entirely. GitHub Actions authenticates to npm via short-lived OIDC tokens, and npm automatically generates provenance attestations linking the published package to the exact Git commit and workflow that built it (GitHub, 2026).

Note: This layer applies if you publish packages to npm. If your team only consumes packages, skip to the verification command below.

Set up trusted publishing in your publish workflow:

name: Publish
on:
release:
types: [published]
permissions:
contents: read
id-token: write # Required for OIDC
jobs:
publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
node-version: 22
registry-url: "https://registry.npmjs.org"
- run: npm ci --ignore-scripts
- run: npm publish --provenance --access public

No NPM_TOKEN secret. No stored credentials. The OIDC token is short-lived and scoped to the specific workflow run.

The Bitwarden attack exploited OIDC permissions (id-token: write) combined with insufficient branch protection rules. The attackers edited the publish workflow five times in succession to stage the malicious payload (Endor Labs, 2026). OIDC is the right tool, but it must be paired with branch protection that prevents workflow file modifications without review.

For consumers: verify provenance of packages you install:

Terminal window
npm audit signatures

Key insight: The Bitwarden attack exploited OIDC-based npm publishing with id-token: write permissions after the attacker modified the publish-cli.yml workflow five times in succession to stage the malicious payload (Endor Labs, 2026). OIDC trusted publishing is essential, but it must be combined with branch protection rules that prevent unauthorized workflow modifications.


What Does the Complete CI Workflow Look Like?

All 5 layers combine into two GitHub Actions workflow files. Here’s the install-and-test workflow with Layers 1-4:

.github/workflows/ci.yml
name: CI
on: [push, pull_request]
permissions:
contents: read
pull-requests: write
jobs:
security:
runs-on: ubuntu-latest
steps:
# Layer 4: All actions pinned to SHA
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/setup-node@0a44ba7841725637a19e28fa30b79a866c81b0a6 # v4.0.4
with:
node-version: 22
# Layer 2: Validate lockfile before install
- name: Validate lockfile
run: npx lockfile-lint --path package-lock.json --type npm --allowed-hosts npm --allowed-schemes "https:"
# Layer 1: Deterministic install from lockfile
- name: Install dependencies
run: npm ci --ignore-scripts

And the dependency review workflow for PRs (Layer 3):

.github/workflows/dependency-review.yml
name: Dependency Review
on: [pull_request]
permissions:
contents: read
pull-requests: write
jobs:
review:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1
- uses: actions/dependency-review-action@4901385134134e04cec5fbe5ddfe3b2c5bd5d976 # v4.0.0
with:
fail-on-severity: moderate
comment-summary-in-pr: always

Layer 5 (publish with OIDC) is shown in the previous section. Add it as a separate workflow triggered on releases.

Try it now: Copy the CI workflow above into .github/workflows/ci.yml in your repo. Push to a branch and open a PR. The lockfile validation and dependency review should both run. If either fails, you’ve found a gap in your current setup. Fix it before merging to main.

This completes the npm defense series. Level 1 protects your laptop. Level 2 protects your AI agent. Level 3 protects your CI pipeline. Level 4 is the runbook for when something still gets through.

Shipping production code with AI tools? The full security picture goes beyond npm. Read the 6-layer AI Coding Security Checklist →


FAQ

Does this work with pnpm and yarn?

pnpm install --frozen-lockfile is the pnpm equivalent of npm ci. Yarn has yarn install --immutable. lockfile-lint supports all three lockfile formats. The dependency review action and SHA pinning are package-manager agnostic since they operate at the GitHub level, not the package manager level.

What about private registries?

lockfile-lint supports allowlisting private registry URLs in .lockfile-lintrc.json via the allowedUrls field. npm ci works with .npmrc registry config. Provenance attestation is currently only available for public npm packages. For private packages, continue using scoped npm tokens with the shortest possible expiration.

How do I pin actions that update frequently?

Use Dependabot or Renovate with SHA pinning enabled. Add .github/dependabot.yml with package-ecosystem: "github-actions". They submit PRs when a pinned action has a new release, including the new SHA. You review the diff, approve, merge. No manual SHA lookups needed.

Will this slow down CI?

npm ci is typically faster than npm install because it skips dependency resolution. lockfile-lint adds about 2 seconds. The dependency review action adds about 10 seconds on a PR workflow. Net CI time is usually lower with npm ci, even with the added security steps.

What if my team pushes back on the complexity?

Start with Layer 1 (npm ci instead of npm install) and Layer 4 (SHA pinning with Dependabot). Those two catch the most common attack vectors with the least workflow change. Layer 1 is a one-word change in your workflow file. Layer 4 is a Dependabot config file. Add Layers 2, 3, and 5 when the team is comfortable.