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

Enforcing npm audit Thresholds in CI

npm audit is easy to run and hard to operationalize: run it with no threshold and CI fails on every low-severity advisory in a dev-only tool; suppress it entirely and you ship known-vulnerable runtime code. This page shows how to wire a severity threshold into CI so the build fails for the right reasons, how to verify registry signatures, and how to allowlist advisories you have triaged and accepted.

Exact Symptom

A pipeline that runs a bare npm audit blocks merges with output like this, even when the advisory is in a transitive dev dependency that never ships:

Exact Symptom A pipeline that runs a bare npm audit blocks merges with output like this, even when the advisory is in a transitive dev Exact Symptom A pipeline that runs a bare npm audit blocks merges with output like this, even when the advisory is in a transitive dev dependency that never ships:
Exact Symptom — the core idea of this section at a glance.
# npm audit report

minimatch  <3.0.5
Severity: low
Regular Expression Denial of Service - https://github.com/advisories/GHSA-...
fix available via `npm audit fix`
node_modules/eslint/node_modules/minimatch

3 low severity vulnerabilities
npm error code 1
npm error audit found vulnerabilities
Process completed with exit code 1.

The opposite failure is silent: a job runs npm audit || true, swallows the non-zero exit, and a critical runtime CVE sails through unnoticed.

Root Cause Analysis

npm audit exits non-zero if any advisory at or above the configured level is found. With no --audit-level, the default threshold is low, so a single low-severity advisory anywhere in the tree — including dev tooling that never reaches production — fails the build. The fix is not to disable auditing but to scope it: choose a severity threshold that matches your risk tolerance, restrict the scan to dependencies that actually ship, and explicitly allowlist advisories you have reviewed and cannot yet fix. This is one control within Supply-Chain Security Hardening; audit alone catches only known vulnerabilities, so treat it as a gate, not a guarantee.

npm audit decision flow An advisory is checked against the severity threshold and the allowlist before the build passes or fails. advisory found in tree at or above threshold? --audit-level=high allowlisted? audit-ci config pass exit 0 fail exit 1 yes below: pass no yes
An advisory fails the build only when it meets the severity threshold and is not on the reviewed allowlist.

The reason an unthresholded npm audit is unworkable in CI is that the advisory database is large and constantly growing, and much of it concerns low-severity issues or code paths a given project never exercises. A gate that fails on every advisory turns red on issues the team cannot or should not act on immediately, which trains everyone to ignore the audit or disable it — the worst outcome, because then genuine high-severity findings pass unnoticed too. A threshold restores signal by failing only on the severities that warrant blocking a release.

Severity is also not the same as exploitability in your context. A high-severity regular-expression denial-of-service in a parser you only feed trusted, bounded input is a different risk than the same flaw in code that parses untrusted uploads, and npm audit cannot know which you have. The threshold is therefore a policy decision — which severities block automatically — layered with a triage process for the findings that need human judgment about reachability, rather than a claim that everything below the threshold is safe.

The audit's severity ratings come from the advisory database's assessment of each vulnerability in the abstract, which is why a threshold, not a zero-tolerance gate, is the workable policy. A high-severity rating reflects the worst-case impact of a flaw, not its impact in your specific usage — the same regular-expression denial-of-service is critical for code parsing untrusted input and irrelevant for code parsing a fixed, trusted config. Gating on severity gives you a consistent, automatable bar; the reachability judgment that severity cannot capture is what the human triage step adds on top for the findings that block.

Resolution & Configuration

Follow these steps to turn a noisy or bypassed audit into a meaningful gate.

Resolution & Configuration Follow these steps to turn a noisy or bypassed audit into a meaningful gate. Resolution & Configuration Follow these steps to turn a noisy or bypassed audit into a meaningful gate.
Resolution & Configuration — the core idea of this section at a glance.
  1. Set an explicit severity threshold. Pick the lowest severity you are willing to block on. Most teams gate on high (which also includes critical).

    npm audit --audit-level=high
  2. Scope the scan to shipping dependencies. Dev-only advisories rarely warrant blocking a release. Use --omit=dev (the modern replacement for --production) so the gate reflects runtime risk.

    npm audit --audit-level=high --omit=dev
  3. Understand the exit codes. npm audit exits 0 when nothing meets the threshold and 1 when something does. There is no separate code per severity, so the threshold flag is the only knob that controls pass/fail. Never wrap it in || true — that discards the signal entirely.

  4. Verify registry signatures separately. This is a different check: it confirms the tarballs you installed were signed by the registry, catching tampering rather than known CVEs.

    npm audit signatures
  5. Adopt an allowlist tool for triaged advisories. When an advisory is at or above your threshold but cannot be fixed yet (no patch upstream, or a false positive for your usage), you need to accept it explicitly without lowering the whole gate. audit-ci and better-npm-audit both support this.

    npm install --save-dev audit-ci
    {
      "$schema": "https://github.com/IBM/audit-ci/raw/main/docs/schema.json",
      "high": true,
      "critical": true,
      "allowlist": [
        "GHSA-1234-5678-9abc",
        "qs|express>qs"
      ],
      "report-type": "summary"
    }

    The high: true/critical: true keys set the threshold; each allowlist entry is a specific advisory ID (or a package|path for path-scoped acceptance) you have reviewed. Run it in place of bare audit:

    npx audit-ci --config audit-ci.json
  6. Document each allowlist entry. Treat the allowlist as a register of accepted risk. Add a comment or a tracking issue per entry and an expiry date, so suppressions do not become permanent blind spots.

Emit machine-readable output so the gate is deterministic and the result is diffable over time, and gate on high and critical:

- run: npm audit --audit-level=high
- run: npm audit --json > audit.json   # artifact for triage + trend tracking

For a finding that is genuinely unreachable or awaiting an upstream patch, record an explicit, reviewed exception rather than lowering the global threshold, so the gate stays strict for everything else. When a fix exists, prefer a scoped overrides pin for a transitive advisory over a forced upgrade, so remediation does not ship an unrelated breaking change.

CI Validation

Wire the threshold and signature checks into the pipeline. This step belongs in the security gate, before any publish job runs.

CI Validation Wire the threshold and signature checks into the pipeline. CI Validation Wire the threshold and signature checks into the pipeline.
CI Validation — the core idea of this section at a glance.
# .github/workflows/audit.yml
name: Dependency Audit
on:
  pull_request:
  push:
    branches: [main]

permissions:
  contents: read

jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      # Install without lifecycle scripts so audited code never executes here
      - run: npm ci --ignore-scripts

      # Threshold gate with reviewed allowlist
      - run: npx audit-ci --config audit-ci.json

      # Confirm registry signatures over installed tarballs
      - run: npm audit signatures

Validate locally before pushing:

# Reproduce the gate exactly
npx audit-ci --config audit-ci.json; echo "exit: $?"

# Inspect machine-readable output to triage a specific advisory
npm audit --json --audit-level=high | npx --yes jq '.vulnerabilities | keys'

Confirm the gate behaves as intended by running it against a known state before trusting it in CI. A dry run shows the current findings and their severities, and forcing the threshold surfaces exactly what would block:

# See all findings and their severities
npm audit
# What the CI gate will actually fail on
npm audit --audit-level=high; echo "exit: $?"
# Machine-readable detail for a specific advisory
npm audit --json | jq '.vulnerabilities | to_entries[] | select(.value.severity=="high")'

A non-zero exit from --audit-level=high is the CI failure signal; a zero exit with visible moderate findings confirms the threshold is filtering as designed rather than hiding a genuine high-severity issue.

Prevention & Guardrails

  • Run the audit step on every pull request, not just on main, so advisories are caught before merge.
  • Keep --ignore-scripts on the install that precedes auditing; you are about to scan untrusted code, not run it.
  • Pin the threshold in version control (the audit-ci.json), never inline in a shell command where it drifts between jobs.
  • Give every allowlist entry an owner and an expiry; review the allowlist on each release.
  • Pair the gate with automated update PRs (Dependabot/Renovate) so the common fix — a patched version — lands quickly.
  • Run npm audit signatures as a standalone step; a failing threshold and a failing signature check are different incidents.
Prevention & Guardrails Prevention & Guardrails in production JavaScript package workflows. Prevention & Guardrails Prevention & Guardrails in production JavaScript package workflows.
Prevention & Guardrails — the core idea of this section at a glance.
  • Gate CI on --audit-level=high so only high and critical findings block, keeping the signal actionable.
  • Route lower-severity findings to the update bot as ordinary pull requests rather than blocking builds on them.
  • Record accepted-risk advisories explicitly, with a reason and a review date, instead of lowering the threshold.
  • Track the audit result over time so a rising count is visible before it becomes a backlog.

Triaging a finding above the threshold

When the gate fails on a high or critical advisory, a quick triage decides the response before you touch any code. Read the advisory for three things: the severity, whether the vulnerable code path is reachable from how you actually use the package, and whether a patched version exists within a compatible range. A compatible patch is a routine fix — apply it, ideally through the update bot. A patch that only exists across a major boundary needs the reachability weighed before you accept a breaking change. A genuinely unreachable path is a candidate for a documented, time-limited exception.

Advisory triage How reachability and patch availability decide the response. Is there a compatible patch? yes apply via bot major only weigh reachability unreachable document + accept
Above the threshold, severity plus reachability plus patch availability decide the fix.

The key discipline is to make the decision explicit and recorded rather than reflexive. npm audit --json gives you the full advisory detail — the patched version, the dependency paths — so you can see whether the vulnerable package is a direct or transitive dependency and choose the surgical fix accordingly. Recording the outcome, whether patched, deferred, or accepted, turns the audit from a recurring alarm into an auditable trail of considered decisions, which is what lets a team run a strict gate without it becoming noise they learn to ignore.

Keeping the audit signal healthy over time

A thresholded audit stays useful only if the findings it surfaces are actually acted on, so tracking the result over time is what keeps it from silently accumulating a backlog. Emitting the audit as JSON and storing it as a CI artifact lets you see whether the count of high and critical findings is trending down as the update bot lands fixes, or up as new advisories arrive faster than they are remediated. A rising trend is a signal to invest in the update cadence — more grouping, a faster security lane, or a narrower automerge policy — before the backlog becomes unmanageable.

Audit health Track the trend, keep exceptions dated, revisit on schedule. emit JSON artifact trend over time dated exceptions no permanent blind spots revisit on schedule stays strict
Trend tracking and exception hygiene keep the gate reflecting current risk.

The complementary discipline is to keep the exception list honest. Accepted-risk advisories should carry a reason and a review date, so a temporary acceptance for an unreachable path does not become a permanent blind spot. Revisiting the list on a schedule — removing exceptions whose upstream fix has shipped, re-evaluating those whose reachability has changed — keeps the gate strict where it should be. Together, trend tracking and exception hygiene turn the audit from a one-time setup into a maintained control that reflects the project's actual, current risk rather than the state it was in when the gate was first added.

Auditing transitive versus direct dependencies

Where a vulnerable package sits in the dependency tree shapes how you remediate it, so a useful part of triage is distinguishing a direct from a transitive advisory. A direct dependency you declared can be bumped within its range, or upgraded deliberately if the fix requires a major; the change is visible in your manifest and reviewable. A transitive dependency — one pulled in by something you declared — has no direct version to bump, so the surgical fix is a scoped override that pins the patched transitive version without touching your direct dependency's major.

Direct or transitive How the dependency's position shapes remediation. Where is the vulnerable package? direct dependency bump in range transitive scoped override major fix only deliberate upgrade
Bump a direct dependency; pin a transitive one with a scoped override.

The npm audit --json output shows the dependency paths for each advisory, which is what lets you make this distinction quickly: a path of length one is a direct dependency, a longer path is transitive. For a transitive advisory, an override that forces the patched sub-dependency version, verified with npm ls to confirm every path moved, clears the finding without the collateral damage of a forced upgrade elsewhere. Treating direct and transitive advisories with their appropriate tools — a deliberate upgrade versus a scoped pin — keeps remediation precise, so clearing an audit finding does not introduce a breaking change in an unrelated part of the graph.

Fitting the audit into the wider security gate

A thresholded audit is most effective as one check in a security gate rather than a standalone step, because a vulnerability threshold alone leaves other supply-chain surfaces open. The audit catches known-vulnerable versions, but it says nothing about install-time code execution, resolution from untrusted hosts, or the authenticity of a package's origin. Combining the audit with --ignore-scripts on the install, a lockfile-lint host allow-list, and npm audit signatures for provenance verification covers those surfaces, so a package must be free of known vulnerabilities and resolved from an allowed host and unable to run arbitrary install code and verifiably from its claimed source.

Layered security gate Audit plus script blocking, host lint, provenance. audit threshold known vulnerabilities ignore-scripts install-time code lockfile-lint resolution hosts audit signatures verified origin
The audit is the vulnerability dimension of a gate that also covers code, hosts, and origin.

Structuring these as one CI job means every push is measured against the whole posture, and each check is fast and deterministic so the gate adds seconds rather than minutes. The audit's role in that gate is the vulnerability dimension specifically, gated at a severity that keeps the signal actionable. Wiring an update bot to open remediation pull requests for the findings the gate surfaces closes the loop, so the audit does not just fail builds but drives a steady stream of reviewable fixes — which is what keeps the dependency graph both current and free of known high-severity vulnerabilities over time rather than only at the moment the gate was added.

Frequently Asked Questions

What is the difference between --audit-level and --omit=dev? --audit-level sets the severity at which the command exits non-zero (the pass/fail threshold). --omit=dev changes which dependencies are scanned, excluding devDependencies so the gate reflects only code that ships. Use both together: gate on high, scoped to runtime deps.

Should I use npm audit fix in CI? No. npm audit fix mutates the lockfile and can perform major-version upgrades, which is the opposite of what a deterministic CI install should do. Run audit fix locally, review the diff, commit the lockfile, and let CI verify with a frozen install. The CI gate should only report and fail, never mutate.

How do I allowlist a false positive without weakening the whole gate? Add the specific advisory ID to the allowlist array in audit-ci.json (or the equivalent in better-npm-audit). This accepts exactly that one advisory while every other high/critical finding still fails the build. Annotate it with the reason and an expiry so the suppression is revisited.

Why does npm audit show different results than my IDE or npm audit signatures? npm audit reports known advisories from the registry database for your current tree; results change as new advisories are published, so two runs on different days differ legitimately. npm audit signatures is unrelated — it verifies cryptographic signatures on installed tarballs and does not consult the advisory database at all.

What audit level should block a build?

High and critical (--audit-level=high). Blocking on moderate and low turns the gate into noise the team learns to disable, which lets genuine high-severity findings through too. Route lower severities to the update bot instead of failing builds on them.

How do I handle an advisory with no compatible fix?

If the vulnerable path is genuinely unreachable, record a documented, time-limited exception rather than lowering the global threshold. If a fix exists only across a major boundary, weigh reachability and schedule the major deliberately — never force it through audit fix --force.

Should npm audit fix run in CI?

No — only npm audit (as a thresholded gate) belongs in CI. audit fix mutates the lockfile and should run in a reviewed pull request, so a range-compatible-but-behavior-changing update is caught by tests before merge.

How do I test that my audit threshold is configured correctly?

Run npm audit --audit-level=high locally and check the exit code — non-zero means it would fail CI. Compare against a plain npm audit showing all findings to confirm the threshold is filtering low-severity noise rather than hiding a genuine high-severity issue.

How do I remediate a transitive vulnerability the audit flags?

Pin the patched transitive version with a scoped overrides entry and verify with npm ls <pkg> that every path moved to it. Unlike audit fix --force, this clears the advisory surgically without upgrading an unrelated direct dependency across a major boundary.

Is an audit threshold enough for supply-chain security?

No — it covers known vulnerabilities only. Combine it with --ignore-scripts (install-time code), lockfile-lint (resolution hosts), and npm audit signatures (provenance) in one CI gate, so a dependency must be vulnerability-free, from an allowed host, unable to run arbitrary code, and verifiably sourced.

Related

Supply-Chain Security Hardening