What you'll learn
  • Where npm audit's warnings come from and what your dependency tree is checked against
  • How to read a finding: severity, affected versions, and the path that pulled the package in
  • Why the same advisory can be noise in one project and urgent in another
  • What npm audit fix changes, and why the --force variant can break a working project
  • What package-lock.json protects against and why it gets committed
on this page

Somewhere near the bottom of every npm install, after the progress bar clears, npm likes to drop a line like this:

5 vulnerabilities (2 moderate, 3 high)

To address all issues, run:
  npm audit fix

First instinct: you broke something. Second instinct: hackers are already inside the to-do app you started on Tuesday. Neither is true. That line is npm running a background check on your dependencies and reporting everything it found, with zero context about whether any of it matters for your project.

Context is the whole game here. By the end of this guide, that number should read less like an alarm and more like unread mail: worth opening, rarely an emergency.

Your app is mostly other people’s code

Spin up a fresh starter template and compare folders. Your src is a handful of files you could read in one sitting. node_modules is hundreds of packages and hundreds of megabytes, all written by strangers, all pulled in because your handful of files asked for it.

That ratio is normal. Every Express server and every React app sits on a pile of packages, each of which has its own dependencies, which have theirs. The whole pile is your dependency tree, and you picked maybe five entries in it on purpose. The rest came along for the ride.

When something in that pile has a known bug, you want to hear about it. That is the entire job of npm audit: a smoke detector for all the code in your app that you didn’t write.

What npm audit actually checks

When you run npm audit (and npm install runs a quick summary version automatically, which is where the surprise line comes from), npm takes the name and version of every package in your tree, straight out of package-lock.json, and checks them against a public database of advisories: published reports of known vulnerabilities, sourced these days from the GitHub Advisory Database. Each advisory says, roughly: package X, in versions between A and B, has this bug, and here is how bad the bug can get.

Two things the audit does not do:

  • It never reads your code.
  • It has no idea whether your project can actually reach the broken part.

It is a list lookup with perfect memory and no judgment. Useful, but every decision is still yours, which is why reading the report matters more than any command.

Reading a real finding

Say your project has a small script that walks a content folder, and for that you installed a package called recursive-readdir. One day the audit shows this:

[ audit walkthrough ]
npm audit
# npm audit report

minimatch  <3.0.5
Severity: high
minimatch ReDoS vulnerability - https://github.com/advisories/GHSA-f8q6-p94x-37v3
fix available via `npm audit fix`
node_modules/minimatch
  recursive-readdir  1.2.0 - 2.2.2
  Depends on vulnerable versions of minimatch
  node_modules/recursive-readdir

1 high severity vulnerability

To address all issues, run:
  npm audit fix

One finding. Every line is doing a job; the list below decodes them in order.

npm ls minimatch
[email protected]
`-- [email protected]
  `-- [email protected]

The chain of custody: you asked for recursive-readdir, and recursive-readdir brought minimatch along.

Top to bottom:

  • minimatch <3.0.5: the vulnerable package and which versions are affected. Anything older than 3.0.5 has the bug; 3.0.5 and newer have the fix.
  • Severity: high: the advisory’s rating of how bad the worst case is. More on this in a minute, because this one word causes most of the panic.
  • The title and link: ReDoS is short for Regular Expression Denial of Service, where a crafted input string makes a regular expression churn for ages and peg a CPU. The link is the actual homework. It tells you how the bug triggers and which version fixes it.
  • fix available via npm audit fix: a patched version exists and npm can get there without breaking changes. When that is impossible, this line offers npm audit fix --force instead, which is your cue to slow down. We will get to why.
  • The indented block: the path. You never installed minimatch. You installed recursive-readdir, and it pinned an old minimatch under the hood.

That last part is the difference between a direct dependency (listed in your package.json because you typed npm install something) and a transitive dependency (brought in by a dependency of a dependency, sometimes five levels deep). Most findings are transitive, because most of the tree is. npm ls <package> shows you the chain whenever the report’s path is murky.

Severity is a starting point

Advisories are rated low, moderate, high, or critical. The rating describes how bad things can get for code that uses the package in the vulnerable way. It is scored in the abstract, for the worst-placed user of the package. It knows nothing about your project.

So after severity, ask the two questions the report cannot answer.

Where does this package run? Your package.json splits dependencies into two buckets:

{
  "dependencies": {
    "express": "^4.18.2"
  },
  "devDependencies": {
    "eslint": "^9.0.0"
  }
}

dependencies ship with your app and run in production. devDependencies are tooling: bundlers, linters, test runners. They run on your machine at build time and never get deployed. A static site is the extreme case: what reaches visitors is inert HTML, CSS, and JS files, so a bug in the build chain never ships anywhere.

Who controls the input the vulnerable code sees? The minimatch ReDoS above needs someone to feed a malicious pattern to the matching function. In a build script, the someone with that power is you, on your own laptop. The threat model is you pranking yourself. Now picture the same severity label on an advisory in qs, the library Express uses to parse query strings. Every request from every stranger on the internet flows through that code, and strangers control their own requests by definition. Same word, completely different urgency.

That is the honest answer to “are 5 vulnerabilities bad?”: a static site with five findings in devDependencies is usually fine to ship today. A server with one finding in its request-parsing path is not. Context decides, and the report cannot see context.

To get the production-only view:

npm audit --omit=dev

If that comes back clean, everything in the report lives in your tooling, and the clock you are on says “soon” rather than “now.”

npm audit fix, and the flag that breaks things

Plain npm audit fix is the calm version. It installs updated versions only where the fix fits inside the version ranges already in your package.json: patch and minor bumps, the kind semver (semantic versioning, the major.minor.patch numbering scheme) promises are backward compatible. In the example above it would bump recursive-readdir to a release that uses a patched minimatch, and nothing about your code needs to change. Run your build and tests afterward anyway, because “promised compatible” and “actually compatible” occasionally part ways.

npm audit fix --force is a different machine. It is allowed to install new major versions of your top-level packages, and a major version is, by definition, allowed to contain breaking changes. It can also downgrade packages when that is the only path to a patched tree. People have run it on a working project and watched it yank their build tooling several major versions in one direction or the other, leaving a package.json that no longer resembles the project they had ten minutes earlier.

The lockfile is your seatbelt

Your package.json asks for ranges: "express": "^4.18.2" means “this version or any compatible newer one,” so which exact version you get depends on the day you install. package-lock.json is the receipt: the exact version of every package in the entire tree, plus an integrity hash for each one, a fingerprint of the package’s contents.

Commit that file. Always. Two reasons.

First, reproducible installs. With the lockfile in git, npm ci (the install command build servers use) recreates the exact tree you tested. Your laptop, a teammate’s laptop, and the deploy build all run the same bytes.

Second, the seatbelt. Suppose a popular package’s maintainer gets phished tonight and a malicious patch version goes out. With floating ranges and no lockfile, the next fresh install anywhere picks the poisoned version up automatically, because it fits the range. With a committed lockfile, every install keeps fetching the exact version you locked until you choose to update, and the integrity hash means a tampered copy of even the right version number fails the install instead of sneaking through. Attacks like this have hit widely used packages more than once; the lockfile will not stop every one, but it removes the automatic path. Nothing new enters your tree without a diff you can see.

One related habit: when audit warnings annoy people, a depressingly common “fix” is deleting package-lock.json and reinstalling, shuffling versions until the number changes. Don’t. Regenerating the lockfile re-floats every range at once, which is the opposite of careful, and any warnings that vanish do so by luck.

A sane routine

When a finding shows up, this is the whole job:

  1. Read the advisory. Click the link in the report. Note which versions are affected, which version fixes it, and what an attacker would actually have to do.
  2. Find the path. npm ls <package> shows what pulled it in. npm audit --omit=dev tells you whether production even contains it.
  3. Decide if it is reachable. Does the vulnerable code run in production? Can anyone besides you feed it input? Two yeses mean act now. Two nos mean schedule it like ordinary maintenance.
  4. Update deliberately. npm audit fix for the compatible bumps. For a direct dependency, npm install <package>@latest after a skim of its changelog. For a stubborn transitive one, you often wait for the package in the middle to update its own pin, and that is normal.
  5. Re-run and commit. Build, test, then commit package.json and package-lock.json together so the fix is pinned for everyone.

If you wire audit into CI (continuous integration, the checks that run on every push), give it a threshold so it only fails the build at a severity you would actually stop for:

npm audit --omit=dev --audit-level=high

That exits with an error only when production dependencies have findings rated high or critical, which keeps real alarms loud and the flyers out of your build log.

Shipping with a nonzero number

The first real project you ship will probably have a nonzero audit count, and so will the second, because new advisories get published every day against packages you already have. That is the normal state of a living dependency tree, and you can ship responsibly inside it, provided you have done the ten minutes of reading.

So before deploy day on whatever you are building now: run npm audit, walk the routine on anything it reports, fix what production can reach, and write down somewhere (a line in the README is plenty) why the rest can wait. The portfolio site with five moderates in its build tooling goes live tonight. The little Express API with a high in its request-parsing path goes live after one targeted npm install and a test run. Telling those two apart is most of dependency security, and you can do it now. The rest of the pre-flight pass, headers, rate limits, and friends, is next: security before you ship.

signal check

Can you answer these without scrolling back up?

  1. Your static site shows 5 vulnerabilities, all in devDependencies. Do you have to fix them before you deploy?

    Usually no. devDependencies run on your machine at build time and never ship to visitors; a static site deploys inert HTML, CSS, and JS. Read the advisories and update when convenient, but it is rarely a deploy blocker. The same count in a server's production dependencies, especially anything that parses requests, deserves a real look first.

  2. What is the difference between npm audit fix and npm audit fix --force?

    Plain npm audit fix only installs updates that fit the version ranges already in your package.json, the patch and minor bumps semver calls backward compatible. The --force variant is allowed to install new major versions of your top-level packages, and can even downgrade them, and major versions are allowed to contain breaking changes. It can leave a working project broken.

  3. Why does package-lock.json get committed to git?

    It records the exact version and an integrity hash for every package in the whole tree, so npm ci reproduces the tree you tested on any machine, and a newly published compromised version of a dependency cannot slide in through a floating version range until you deliberately update.

progress is stored in this browser only