Failing npm audit Builds on High-Severity Advisories
The default npm audit behaviour is a poor CI gate: it exits non-zero on any advisory of any severity, including low-severity issues in dev-only tooling, so teams learn to ignore it or strip it from the pipeline entirely. The goal of this guide is a precise gate — one that fails a pull request only on high and critical advisories in production dependencies, while routing unfixable transitive advisories through an audited, expiring allowlist. This is the Node.js implementation of the enforcement pattern described in the parent Dependency Scanning & CI Security Gates guide, itself part of Supply Chain & Dependency Security. If your stack is Python rather than Node, the sibling gating pip-audit in Python CI walkthrough covers the equivalent pip-audit gate.
Prerequisites
- Node.js 18+ and npm 9+ (the
--omitflag andoverridesblock require modern npm) - A committed
package-lock.jsonresolved withnpm installornpm ci - A CI runner (examples use GitHub Actions) with permission to fail a required check
- Optional but recommended:
audit-ciorbetter-npm-auditinstalled as a dev dependency for allowlist support
Expected Outcomes
- CI fails only on high and critical advisories, and only in production dependencies
- Unfixable transitive advisories are suppressed individually through an expiring allowlist file
- Every other new high or critical advisory still breaks the build
- Developers get a clear, machine-readable report rather than a wall of low-severity noise
Step 1: Baseline the Audit Output
Before gating, understand what your project currently reports. Run the audit in JSON mode and count findings by severity so you can set a threshold that is enforceable today rather than aspirational.
# Full human-readable report
npm audit
# Machine-readable, grouped for triage
npm audit --json > audit-report.json
# Count advisories by severity from the JSON report
npm audit --json \
| node -e 'let d="";process.stdin.on("data",c=>d+=c).on("end",()=>{const j=JSON.parse(d);console.log(j.metadata.vulnerabilities)})'
The metadata.vulnerabilities object returns counts like { info: 0, low: 4, moderate: 2, high: 1, critical: 0 }. If the current high plus critical count is non-zero, resolve or explicitly except those advisories before turning the gate to blocking — otherwise the very first CI run fails on pre-existing debt. Attempt the automated path first:
# Apply fixes within existing semver ranges (safe)
npm audit fix
# Preview breaking upgrades without applying them
npm audit fix --force --dry-run
Reserve --force for a deliberate, reviewed upgrade; it can install major-version bumps that break your build.
Step 2: Wire the CI Gate with Severity and Scope Flags
Two flags turn npm audit into a targeted gate. --audit-level=high makes the command exit non-zero only when a high or critical advisory is present, and --omit=dev excludes development dependencies from the resolved tree so build-time tooling does not fail a production gate.
# .github/workflows/npm-audit-gate.yml
name: npm audit gate
on: [pull_request]
jobs:
audit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 20
cache: npm
- name: Install from lockfile
run: npm ci
- name: Fail on high or critical production advisories
run: npm audit --audit-level=high --omit=dev
npm ci installs strictly from package-lock.json, guaranteeing the audited tree matches the tree the build would ship. The npm audit --audit-level=high --omit=dev step exits 0 when only low or moderate advisories exist and non-zero the moment a high or critical production advisory appears, which fails the job and blocks the merge.
To keep dev-tooling advisories visible without blocking, add a second, non-gating step:
- name: Informational full-graph audit (never fails the build)
run: npm audit --audit-level=low || true
Step 3: Manage Exceptions with an Expiring Allowlist
npm audit itself has no allowlist. When a single high-severity advisory sits in an unfixable transitive dependency, wrapping the audit with audit-ci (or better-npm-audit) lets you suppress that one advisory by ID while still failing on everything else. First try to eliminate the advisory with an overrides block, which forces a nested dependency to a patched version:
{
"overrides": {
"semver": "7.5.4",
"tough-cookie@<4.1.3": "4.1.3"
}
}
If no patched version exists upstream, record the advisory in an audit-ci allowlist. Model it as structured data with a justification and an expiry so the exception cannot outlive its reason:
{
"$schema": "https://raw.githubusercontent.com/IBM/audit-ci/main/docs/schema.json",
"high": true,
"critical": true,
"report-type": "important",
"allowlist": [
"GHSA-mwcw-c2x4-8c55"
],
"_exceptions": [
{
"advisory": "GHSA-mwcw-c2x4-8c55",
"package": "nth-check",
"reason": "Transitive via svgo; no fixed release for our pinned parent. ReDoS path not reachable in build.",
"owner": "frontend-platform",
"created": "2026-04-02",
"expires": "2026-07-01"
}
]
}
Wire the wrapper into CI in place of the raw audit, and add a small check that fails when any exception has expired:
- name: Gated audit with allowlist
run: npx audit-ci --config ./audit-ci.jsonc
- name: Reject expired exceptions
run: node scripts/check-audit-exceptions.js audit-ci.jsonc
The gate decision — including the allowlist detour — follows this flow:
Step 4: Verify the Gate Exit Codes
Confirm the gate behaves as intended by exercising each branch and inspecting the exit code, which is what CI keys on.
# Clean production tree -> exit 0 (build passes)
npm audit --audit-level=high --omit=dev
echo "exit: $?" # expect: exit: 0
# Introduce a known-vulnerable package to prove the gate fires
npm install [email protected] # historical prototype-pollution advisories
npm audit --audit-level=high --omit=dev
echo "exit: $?" # expect: exit: 1 (build blocked)
# With the wrapper and a matching allowlist entry -> exit 0 again
npx audit-ci --config ./audit-ci.jsonc
echo "exit: $?" # expect: exit: 0 when the advisory ID is allowlisted
The load-bearing signal is the exit status: 0 passes the CI step, non-zero fails it. Roll back the deliberately vulnerable install with npm install lodash@latest once verified.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
| CI fails on advisories in a test runner or bundler | Dev dependencies included in the gate | Add --omit=dev to the gating audit; run a separate non-blocking full audit for visibility |
npm audit fix reports “fixed 0 of N” for a high advisory |
Vulnerable package is transitive and its parent pins an old range | Add an overrides block forcing the nested package to a patched version, or allowlist it with an expiry |
| Gate passes locally but fails in CI | Local node_modules is stale; lockfile drifted from installed tree |
Run npm ci (not npm install) in CI so the audited tree matches the lockfile exactly |
| audit-ci ignores the allowlist entry | Advisory identifier format mismatch (GHSA vs numeric) | Match the exact ID printed by npm audit --json; audit-ci keys on the advisory URL or GHSA ID |
| Audit passes but a new critical ships to production | Only a PR gate is configured; no scheduled re-scan | Add a nightly npm audit job so advisories published after merge are caught |
Common Implementation Mistakes
Frequently Asked Questions
Why does npm audit report advisories that npm audit fix cannot resolve?
The advisory lives in a transitive dependency whose parent has not released a version that requires the patched child, and npm audit fix only bumps packages within your declared semver ranges. Force the fix with an overrides block in package.json that pins the nested package to a patched version, which npm applies across the whole tree. If no fixed version exists upstream, record an expiring allowlist entry and revisit it when the expiry fires.
Should I run npm audit with or without --omit=dev in CI?
Gate the build on production dependencies only, using --omit=dev, because a vulnerability in a build-time tool such as a test runner or bundler does not ship to users and should not block a release. Run a second, non-blocking audit across the full graph so dev-tooling advisories stay visible in the logs without failing every unrelated pull request. This split keeps the hard gate high-signal.
How do I stop npm audit from failing on a single unfixable advisory?
Wrap npm audit with audit-ci or better-npm-audit and list the specific advisory ID in an allowlist file alongside a justification and an expiry date. The wrapper filters that one advisory from the failure decision while still failing on any other high or critical finding, so the exception is narrowly scoped rather than a blanket disable of the gate. Cap the exception’s lifetime so a fix that later ships upstream is not permanently masked.
Related
- Dependency Scanning & CI Security Gates — the parent guide on scanner selection, thresholds, and exception policy
- Gating pip-audit in Python CI Pipelines — the equivalent gate for Python projects
- Supply Chain & Dependency Security — the parent guide covering the full dependency-risk lifecycle