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:
# 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.
Resolution & Configuration
Follow these steps to turn a noisy or bypassed audit into a meaningful gate.
-
Set an explicit severity threshold. Pick the lowest severity you are willing to block on. Most teams gate on
high(which also includescritical).npm audit --audit-level=high -
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 -
Understand the exit codes.
npm auditexits0when nothing meets the threshold and1when 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. -
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 -
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-ciandbetter-npm-auditboth 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: truekeys set the threshold; eachallowlistentry is a specific advisory ID (or apackage|pathfor path-scoped acceptance) you have reviewed. Run it in place of bare audit:npx audit-ci --config audit-ci.json -
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.
CI Validation
Wire the threshold and signature checks into the pipeline. This step belongs in the security gate, before any publish job runs.
# .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'
Prevention & Guardrails
- Run the audit step on every pull request, not just on
main, so advisories are caught before merge. - Keep
--ignore-scriptson 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 signaturesas a standalone step; a failing threshold and a failing signature check are different incidents.
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.
Related
- Configuring lockfile-lint for Supply-Chain Safety — validate resolved URLs and integrity, a complementary install-time gate.
- Adding SLSA Provenance to Package Releases — prove the origin of what you publish after the audit gate passes.
- Lockfile Management Strategies — frozen installs are a prerequisite for trustworthy audit results.