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
crossenvorreact-dom-routerthat mimics a popular name, hoping a developer fat-fingers an install. The malicious package usually ships apostinstallthat 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 scripts —preinstall,install, andpostinstallscripts run with full shell privileges duringnpm install, before any of your code runs. This is the single most common payload-delivery mechanism. - Compromised maintainer accounts and tokens — a leaked
NPM_TOKENin CI logs, a phished maintainer, or a stolen.npmrclets 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.
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.
# 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:
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.
# 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:
# .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:
- 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_TOKENsecrets. 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.
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:
# 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.
# .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. |
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.
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.
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 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
- Enforcing npm audit Thresholds in CI — set severity gates and allowlists so audit fails for the right reasons.
- Configuring lockfile-lint for Supply-Chain Safety — validate resolved URLs, HTTPS, and integrity before install.
- Adding SLSA Provenance to Package Releases — prove what built your published tarballs.
- Lockfile Management Strategies — the reproducibility foundation every other control builds on.
- Setting Up npm Provenance with GitHub Actions — the OIDC publish pipeline that emits provenance.