Back to publishing & release Automate semantic versioning Publish to the npm registry Harden the supply chain

Supply-Chain Security Hardening

The npm registry is a remote-code-execution channel: every npm install downloads and can execute arbitrary code from hundreds of maintainers you have never met. Supply-chain hardening is the practice of constraining that channel — verifying what you install, restricting what runs, and proving what you publish — so a single compromised dependency or stolen token cannot ship malicious code into your builds or your consumers' production systems.

The Threat Model

Before adding controls, map the attack surface concretely. A modern JavaScript dependency graph has thousands of edges, and an attacker only needs one. The four dominant attack classes are:

  • Typosquatting — a package named crossenv or react-dom-router that mimics a popular name, hoping a developer fat-fingers an install. The malicious package usually ships a postinstall that exfiltrates environment variables.
  • Dependency confusion — an attacker publishes a public package with the same name as your private internal package (e.g. @yourco/secrets). If your installer is misconfigured to fall through to the public registry, it pulls the attacker's higher-version package instead of yours.
  • Malicious postinstall / lifecycle scriptspreinstall, install, and postinstall scripts run with full shell privileges during npm install, before any of your code runs. This is the single most common payload-delivery mechanism.
  • Compromised maintainer accounts and tokens — a leaked NPM_TOKEN in CI logs, a phished maintainer, or a stolen .npmrc lets an attacker publish a trojaned version of a legitimate package. Consumers who run ^ ranges pick it up automatically.

The structural reason all of these work is that installs are transitive and automatic. You vet your direct dependencies; you almost never vet the 1,400 transitive ones, and the resolution rules that decide which version wins are subtle. Understanding those rules — see Dependency Resolution Explained — is a prerequisite for reasoning about where an attacker can inject a substitution.

Supply-chain attack surface and controls Threats entering an install and publish pipeline, each intercepted by a specific defensive control. Threats typosquatting dependency confusion malicious postinstall stolen tokens Install gate audit + lockfile-lint --ignore-scripts Registry gate allowed hosts + 2FA scoped registry Verified build reproducible deps SBOM emitted Publish gate OIDC + provenance SLSA attestation Signed release consumers verify origin proven
Each threat class is intercepted by a specific gate; what survives is a reproducible build and a signed, provenance-bearing release.

This page is part of Package Publishing & Release Engineering, and it ties together the controls that protect both what you consume and what you ship.

A useful supply-chain threat model traces where untrusted code and data can enter your build and runtime. Installation is the first surface: lifecycle scripts from any dependency run automatically with your privileges, so a single compromised transitive package can execute a payload during a routine install. Resolution is the second: a typosquatted or dependency-confused package can be resolved from an unexpected source, substituting attacker code for what you meant to install. Publishing is the third: a stolen or over-scoped token can push a malicious version under a trusted name. Each surface has a matching defense, and hardening means covering all three rather than the one that is top of mind.

The defenses layer because no single one is sufficient. Blocking install scripts stops install-time code but not a vulnerable version; an audit threshold catches known vulnerabilities but not a typosquat; host allow-listing blocks a bad resolution source but not a compromised-but-authentic package; provenance proves origin but not benign intent. Stacking them means an attacker must defeat every gate rather than any one, which is the essence of defense in depth for a dependency graph.

Defense 1 — Vulnerability Scanning with npm audit

npm audit cross-references your installed tree against the registry advisory database and reports known CVEs by severity. The control that matters in CI is the threshold: fail the pipeline only at or above a chosen severity so that a low-severity advisory in a dev tool does not block a release.

Defense 1 — Vulnerability Scanning with npm audit npm audit cross-references your installed tree against the registry advisory database and reports known CVEs by severity Defense 1 — Vulnerability Scanning with npm audit npm audit cross-references your installed tree against the registry advisory database and reports known CVEs by severity.
Defense 1 — Vulnerability Scanning with npm audit — the core idea of this section at a glance.
# Fail the build on high/critical advisories in runtime deps only
npm audit --audit-level=high --omit=dev

# Cryptographically verify that installed tarballs match registry signatures
npm audit signatures

--audit-level=high sets the exit-code threshold; --omit=dev scopes the scan to what actually ships. npm audit signatures is distinct — it verifies the registry's ECDSA signatures over the tarballs you installed, catching registry-side tampering rather than known CVEs. The full pattern for tuning thresholds, allowlisting unavoidable advisories, and handling false positives lives in Enforcing npm audit Thresholds in CI.

Defense 2 — Lockfile Integrity

A lockfile is your strongest reproducibility guarantee, but only if two things hold: it is installed verbatim (never re-resolved), and its contents are validated before install. Always install with the frozen variant in CI:

Defense 2 — Lockfile Integrity A lockfile is your strongest reproducibility guarantee, but only if two things hold: it is installed verbatim (never re- Defense 2 — Lockfile Integrity A lockfile is your strongest reproducibility guarantee, but only if two things hold: it is installed verbatim (never re-resolved), and its contents are validate
Defense 2 — Lockfile Integrity — the core idea of this section at a glance.
npm ci                      # refuses to drift from package-lock.json
pnpm install --frozen-lockfile
yarn install --immutable

These commands fail rather than silently rewrite the lockfile when package.json and the lockfile disagree — which is exactly what you want when an attacker has slipped a substitution into one but not the other. The deeper discipline around committing, regenerating, and merging lockfiles is covered in Lockfile Management Strategies.

Frozen install proves the lockfile was used; it does not prove the lockfile is benign. A tampered lockfile can point a known package name at an attacker-controlled resolved URL over plain HTTP. The control that closes this gap is lockfile-lint, which validates every resolved entry against an allowed-hosts list, enforces HTTPS, and checks integrity hashes — configured in Configuring lockfile-lint for Supply-Chain Safety.

Defense 3 — Constraining Lifecycle Scripts

Lifecycle scripts are the most direct payload path, so disable them by default and re-enable only for the handful of packages that genuinely need to compile native code.

Defense 3 — Constraining Lifecycle Scripts Lifecycle scripts are the most direct payload path, so disable them by default and re-enable only for the handful of pac Defense 3 — Constraining Lifecycle Scripts Lifecycle scripts are the most direct payload path, so disable them by default and re-enable only for the handful of packages that genuinely need to compile nat
Defense 3 — Constraining Lifecycle Scripts — the core idea of this section at a glance.
# Install with all lifecycle scripts disabled
npm install --ignore-scripts

Make it the project default in .npmrc:

ignore-scripts=true

With scripts globally disabled you then explicitly trust specific packages. pnpm formalizes this with onlyBuiltDependencies in pnpm-workspace.yaml, and modern npm offers a cooling-off window that refuses to install package versions newer than a configured age — defeating the "publish malware and hope it spreads before takedown" pattern:

# .npmrc — refuse versions published in the last 3 days
minimum-release-age=4320
# pnpm-workspace.yaml — only these packages may run build scripts
onlyBuiltDependencies:
  - esbuild
  - sharp

minimumReleaseAge (expressed in minutes via .npmrc, or as a duration in pnpm config) buys time for the community to detect and yank a malicious release before it reaches you.

Defense 4 — Locking the Registry and Resolution Path

Dependency confusion is defeated by never letting an internal scope fall through to the public registry. Pin scoped packages to your private registry and disable fallthrough:

Defense 4 — Locking the Registry and Resolution Path Dependency confusion is defeated by never letting an internal scope fall through to the public registry. Defense 4 — Locking the Registry and Resolution Path Dependency confusion is defeated by never letting an internal scope fall through to the public registry.
Defense 4 — Locking the Registry and Resolution Path — the core idea of this section at a glance.
# .npmrc
@yourco:registry=https://npm.internal.yourco.com/
//npm.internal.yourco.com/:_authToken=${INTERNAL_NPM_TOKEN}
registry=https://registry.npmjs.org/

With a scoped registry mapping, @yourco/* resolves only against the internal host; a public package of the same name is never consulted. For belt-and-suspenders, reserve your scope names on the public registry as empty placeholders so an attacker cannot register them.

Defense 5 — Protecting Publish Credentials

Static NPM_TOKEN secrets are the highest-value target in your pipeline. Reduce their blast radius:

Defense 5 — Protecting Publish Credentials Static NPMTOKEN secrets are the highest-value target in your pipeline. Defense 5 — Protecting Publish Credentials Static NPMTOKEN secrets are the highest-value target in your pipeline.
Defense 5 — Protecting Publish Credentials — the core idea of this section at a glance.
  • Enforce 2FA on the npm account and require it for publish (auth-and-writes), so a stolen password alone cannot publish.
  • Prefer short-lived OIDC tokens over long-lived NPM_TOKEN secrets. GitHub Actions can mint a per-job identity that npm trusts, eliminating the stored secret entirely — this is the same mechanism that powers provenance, detailed in Setting Up npm Provenance with GitHub Actions.
  • Scope tokens narrowly: granular access tokens limited to specific packages and a short expiry, never an account-wide automation token committed anywhere a log can capture it.

Defense 6 — Provenance and SLSA

Provenance answers the question a consumer cannot otherwise answer: was this tarball actually built from the source it claims, by the pipeline it claims? npm provenance, backed by SLSA build-level attestations and signed via Sigstore, binds a published version to the exact commit, workflow, and runner that produced it. Consumers verify with npm audit signatures and see a "Built and signed on GitHub Actions" badge. The end-to-end setup — build levels, OIDC, attestation, and verification — is in Adding SLSA Provenance to Package Releases.

Provenance attestation OIDC-signed provenance the registry verifies against source. CI OIDC trusted identity signed attestation source + build registry verifies provenance badge
SLSA provenance proves the tarball came from your CI, not a laptop.

Defense 7 — Automated Updates and SBOMs

Keeping dependencies current is itself a control: most exploited CVEs have a patched version available. Configure Dependabot or Renovate to open update PRs continuously, but gate the merge on your CI security checks rather than auto-merging blindly. Pair this with a generated Software Bill of Materials so you can answer "are we affected by this new CVE?" in seconds rather than hours:

Defense 7 — Automated Updates and SBOMs Keeping dependencies current is itself a control: most exploited CVEs have a patched version available. Defense 7 — Automated Updates and SBOMs Keeping dependencies current is itself a control: most exploited CVEs have a patched version available.
Defense 7 — Automated Updates and SBOMs — the core idea of this section at a glance.
# Generate a CycloneDX SBOM from the installed tree
npx @cyclonedx/cyclonedx-npm --output-file sbom.json

Commit the SBOM as a release artifact alongside the provenance attestation.

CI Security Gate

The following workflow wires every consume-side control into a single gate that runs before any publish job. It is annotated so each step maps to a defense above.

CI security gate Audit, lockfile-lint and script constraints block a bad release. npm audit threshold fail on high lockfile-lint allowed hosts ignore-scripts no arbitrary code
One CI gate stacks audit, host allow-listing and script isolation.
# .github/workflows/security-gate.yml
name: Supply-Chain Security Gate
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read          # least privilege: no write tokens in the gate

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # Defense 3: never run third-party lifecycle scripts during the gate
      - name: Install without scripts
        run: npm ci --ignore-scripts

      # Defense 2: validate lockfile hosts, HTTPS, and integrity hashes
      - name: Lint lockfile
        run: |
          npx lockfile-lint \
            --path package-lock.json \
            --type npm \
            --allowed-hosts npm \
            --validate-https \
            --validate-integrity

      # Defense 1: fail on high/critical advisories in runtime deps
      - name: Audit dependencies
        run: npm audit --audit-level=high --omit=dev

      # Defense 1: verify registry signatures over installed tarballs
      - name: Verify package signatures
        run: npm audit signatures

      # Defense 7: produce an SBOM artifact for the release record
      - name: Generate SBOM
        run: npx @cyclonedx/cyclonedx-npm --output-file sbom.json

      - uses: actions/upload-artifact@v4
        with:
          name: sbom
          path: sbom.json

The permissions: contents: read block is deliberate — the security gate has no business holding a write or publish token, so a vulnerability in any audited tool cannot escalate into a publish.

The CI security gate is where hardening becomes a property of every build rather than an occasional review. A single job that runs a frozen install with ignored scripts, a thresholded npm audit, and a lockfile-lint host check measures every push against the whole posture: the graph resolves reproducibly, carries no known high-severity vulnerabilities, and resolves only from allowed hosts. Each check is fast and deterministic, so the gate adds seconds rather than minutes, and its failure is specific enough to act on immediately.

The gate composes with an update bot to close the loop from detection to remediation. When the audit flags a vulnerable dependency, the bot opens a pull request with the fix — a version bump or a scoped override — that CI validates before a human merges it, so a finding produces a proposed fix rather than just a red build. This turns the security posture into a continuous process: every build is measured, every finding is routed to a reviewable remediation, and the dependency graph stays both current and verified without a periodic manual audit.

A single CI job stacks the install, resolution, and vulnerability defenses so every push is measured against the whole posture:

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts
      - run: npm audit --audit-level=high
      - run: npx lockfile-lint --path package-lock.json --allowed-hosts npm --validate-https
      - run: npm audit signatures

The frozen, script-free install blocks arbitrary install-time code; the thresholded audit fails on high and critical vulnerabilities without drowning in low-severity noise; lockfile-lint restricts resolution to allowed HTTPS hosts; and npm audit signatures verifies registry signatures and provenance attestations. Each check is fast and deterministic, so the gate adds seconds, and an attacker must defeat every layer rather than any single one.

Common Pitfalls & Remediation

Mistake Consequence Fix
Running npm install in CI instead of npm ci Lockfile silently re-resolves, defeating reproducibility and letting a drifted version slip in. Always use npm ci / --frozen-lockfile / --immutable in automation.
npm audit with no --audit-level Pipeline fails on every low dev-tool advisory or is ignored entirely, so nobody trusts it. Set an explicit threshold (--audit-level=high) and an allowlist for accepted advisories.
Leaving lifecycle scripts enabled for all packages A single malicious postinstall runs with full shell access before your code does. ignore-scripts=true by default; allowlist only packages that must compile.
Storing a long-lived account-wide NPM_TOKEN in CI One leaked log line lets an attacker publish trojaned versions of every package. Use OIDC short-lived tokens, granular per-package tokens, and enforce 2FA for writes.
Trusting a frozen lockfile without linting it A tampered resolved URL over HTTP fetches an attacker tarball that still satisfies the hash check it was given. Run lockfile-lint with --validate-https and --allowed-hosts before install.
Auto-merging Dependabot PRs An automated update can pull a freshly compromised version straight to main. Gate merges on the full security workflow; consider minimumReleaseAge.
Common Pitfalls & Remediation Common Pitfalls & Remediation in production JavaScript package workflows. Common Pitfalls & Remediation Common Pitfalls & Remediation in production JavaScript package workflows.
Common Pitfalls & Remediation — the core idea of this section at a glance.

Constraining installs: scripts, hosts, and integrity

The install is where most supply-chain attacks execute, so constraining it is the highest-leverage hardening. Running installs with --ignore-scripts blocks the lifecycle-script vector — arbitrary code from any dependency running with your credentials — while allow-listing only vetted native builds (pnpm's onlyBuiltDependencies) keeps the few packages that legitimately need a build step working. A frozen install against a committed lockfile guarantees the resolved graph is exactly the reviewed one, and integrity hashes in the lockfile verify each downloaded tarball, so a substituted package fails rather than silently entering the tree.

Constrained install Block scripts, verify graph, restrict hosts. ignore-scripts no arbitrary code frozen + integrity reviewed, verified graph lockfile-lint allowed hosts only pinned scopes no confusion
Constraining the install makes the softest supply-chain target something you can reason about.

Restricting where packages may resolve from closes the confusion and typosquat vectors. A lockfile-lint check asserts that every lockfile entry resolves from an allowed host, catching an injected registry or an unexpected source before the poisoned lockfile is merged, and pinning private scopes to a specific registry means an internal name can only ever resolve from the host you control. Together — no arbitrary install code, a frozen and verified graph, and a constrained resolution path — these turn the install from the softest target in the supply chain into something you can reason about precisely.

Provenance, SBOMs, and a CI security gate

Provenance and software bills of materials address the questions of origin and inventory that the install-time defenses do not. Publishing your own packages with an OIDC-signed provenance attestation lets consumers verify a tarball came from your source and build; running npm audit signatures verifies the provenance and signatures of your dependencies in turn. An SBOM records exactly what is in a release — every package and version — which is what lets you answer, when the next widely-used package is found vulnerable, whether you are affected without a frantic manual audit.

CI security gate Install, audit, lint, verify — on every push. frozen + ignore-scripts controlled install audit + lockfile-lint known-good graph provenance verify trusted origin
One gate stacking install, audit, host and provenance checks makes hardening a property of every build.

The practical way to enforce all of this is a single CI security gate that runs on every push: a frozen install with ignored scripts, a thresholded npm audit that fails on high and critical findings, a lockfile-lint host check, and — for publishing pipelines — provenance and signature verification. Each is a fast, deterministic check, and together they make the supply-chain posture a property of every build rather than an occasional review. Wiring an update bot to open remediation pull requests closes the loop, so a finding does not just fail the build but produces a proposed fix a human can review, keeping the dependency graph both current and continuously verified.

The CI security gate is where hardening becomes a property of every build rather than an occasional review. A single job that runs a frozen install with ignored scripts, a thresholded npm audit, and a lockfile-lint host check measures every push against the whole posture: the graph resolves reproducibly, carries no known high-severity vulnerabilities, and resolves only from allowed hosts. Each check is fast and deterministic, so the gate adds seconds rather than minutes, and its failure is specific enough to act on immediately.

The gate composes with an update bot to close the loop from detection to remediation. When the audit flags a vulnerable dependency, the bot opens a pull request with the fix — a version bump or a scoped override — that CI validates before a human merges it, so a finding produces a proposed fix rather than just a red build. This turns the security posture into a continuous process: every build is measured, every finding is routed to a reviewable remediation, and the dependency graph stays both current and verified without a periodic manual audit.

A single CI job stacks the install, resolution, and vulnerability defenses so every push is measured against the whole posture:

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - run: npm ci --ignore-scripts
      - run: npm audit --audit-level=high
      - run: npx lockfile-lint --path package-lock.json --allowed-hosts npm --validate-https
      - run: npm audit signatures

The frozen, script-free install blocks arbitrary install-time code; the thresholded audit fails on high and critical vulnerabilities without drowning in low-severity noise; lockfile-lint restricts resolution to allowed HTTPS hosts; and npm audit signatures verifies registry signatures and provenance attestations. Each check is fast and deterministic, so the gate adds seconds, and an attacker must defeat every layer rather than any single one.

Protecting publish credentials and the release path

The publish step is a high-value target because a stolen credential can push a malicious version under a trusted name, reaching every consumer before anyone notices. The defenses are least-privilege and short-lived: prefer a granular token scoped to a single package over a classic organization-wide token, require two-factor authentication for human publishes, and mint automation tokens that CI reads from a secret store rather than embedding them in a pipeline. The strongest posture removes the standing secret entirely with OIDC-based publishing, where a CI job exchanges its verified identity for short-lived publish rights per run — nothing to leak, nothing to rotate.

Protecting publish credentials and the release path The publish step is a high-value target because a stolen credential can push a malicious version under a trusted name, r Protecting publish credentials and the release path The publish step is a high-value target because a stolen credential can push a malicious version under a trusted name, reaching every consumer before anyone not
Protecting publish credentials and the release path — the core idea of this section at a glance.

Protecting the release path also means constraining what runs during a publish. Publishing from CI rather than a laptop keeps the credential out of personal environments; running the release job with a frozen install and ignored scripts ensures no arbitrary code executes while the artifact is built; and publishing with provenance attaches a signed attestation tying the tarball to the exact source and build, so consumers can verify origin. Together these make the release path something an attacker cannot easily subvert: no long-lived credential to steal, no arbitrary code at build time, and a cryptographic link from the published artifact back to the reviewed source.

Frequently Asked Questions

Does npm audit catch malicious packages, or only known vulnerabilities? Only known vulnerabilities that have a published advisory. It will not flag a brand-new typosquat or a zero-day malicious postinstall. That is why audit is one layer among several — --ignore-scripts, minimumReleaseAge, lockfile-lint, and provenance cover the gaps that advisory scanning cannot.

Is disabling lifecycle scripts safe for every project? For most application installs, yes — the scripts that matter (native module compilation for packages like esbuild or sharp) can be allowlisted explicitly. Set ignore-scripts=true globally and re-enable per package via pnpm's onlyBuiltDependencies or by running the needed build step yourself. Test in CI before rolling out, since a few packages assume their postinstall ran.

How is minimumReleaseAge different from pinning exact versions? Pinning stops unintended upgrades; minimumReleaseAge adds a time delay so that even an intended upgrade cannot pull a version published minutes ago. The two compose: pin your ranges, and let the age window protect the upgrades you do accept by giving the community time to detect and yank malicious releases.

Do I need both npm audit signatures and SLSA provenance? They answer different questions. npm audit signatures verifies the registry signed the tarball you downloaded; provenance verifies who built it and from what source. Provenance is the stronger claim because it ties the artifact to a specific commit and workflow, but signature verification is the cheap baseline you should run on every install regardless.

Where should the security gate run relative to the publish job? The security gate runs on every pull request and on main, with read-only permissions. The publish job is a separate workflow triggered on tags or releases, and it depends on the gate having passed. Keeping them separate ensures the publish credential is never present in the same job that audits untrusted dependency code.

What are the main supply-chain attack surfaces to defend?

Installation (lifecycle scripts run arbitrary code with your privileges), resolution (typosquat or dependency confusion substitutes attacker code), and publishing (a stolen token pushes a malicious version). Each needs its own defense — script blocking, host allow-listing, and scoped credentials — because covering one leaves the others open.

How do I enforce supply-chain hardening in CI?

One security gate on every push: a frozen install with --ignore-scripts, a thresholded npm audit failing on high/critical, a lockfile-lint host check, and provenance/signature verification for publishes. Wire an update bot to open remediation PRs so findings produce fixes, not just failures.

What belongs in a CI security gate?

A frozen install with --ignore-scripts, a thresholded npm audit failing on high/critical, and a lockfile-lint host check — plus provenance and signature verification for publishing pipelines. Each is fast and deterministic, and wiring an update bot to open remediation PRs turns findings into fixes.

What's the highest-value supply-chain hardening step?

Running CI installs with --ignore-scripts, because install scripts are the surface most attacks execute through. Pair it with an audit threshold, lockfile-lint host allow-listing, and provenance, so each layer covers a surface the others do not and an attacker must defeat all of them.

What does a complete CI security gate look like?

One job running a frozen install with --ignore-scripts, a thresholded npm audit, a lockfile-lint host allow-list check, and npm audit signatures for provenance. Each is fast and deterministic, and wiring an update bot to open remediation PRs turns findings into fixes rather than just red builds.

Related

Package Publishing & Release Engineering