Skip to main content

Dependency Security and Vulnerability Scanning in JavaScript

Published: May 24, 2026 Updated: August 30, 2026 Larry Qu 7 min read

Modern JavaScript projects pull in hundreds of transitive dependencies. A single vulnerable package deep in your dependency tree can expose your entire application. The npm ecosystem has had high-profile supply chain compromises — event-stream in 2018, ua-parser-js in 2021, node-ipc in 2022 — each affecting millions of projects. Understanding how to detect, triage, and remediate vulnerabilities is now a core engineering skill, not an optional security concern.

How Dependency Vulnerabilities Work

When a security researcher finds a flaw in an open-source package, the report goes through a coordinated disclosure process. The CVE database and npm Advisory Database receive the report, and npm tags the affected versions. Your npm audit output reflects these advisories cross-referenced against your installed dependency tree.

Vulnerabilities are scored using CVSS (Common Vulnerability Scoring System) on a 0–10 scale. npm maps this to four severity levels:

Severity CVSS Range Action
Critical 9.0–10.0 Block CI, fix immediately
High 7.0–8.9 Fix before next release
Moderate 4.0–6.9 Fix within sprint
Low 0.1–3.9 Fix opportunistically

The key question isn’t just “does a vulnerability exist” but “is the vulnerable code path reachable in your application?” A critical vulnerability in a devDependency that never runs in production is far less urgent than a moderate one in a library your authentication layer calls.

npm Audit

npm audit is your first line of defense. It reads your package-lock.json and checks each resolved version against the npm Advisory Database. Run it after every npm install and in every CI pipeline.

The basic workflow:

# See a summary of vulnerabilities
npm audit

# Get machine-readable JSON for automation
npm audit --json

# Auto-apply non-breaking fixes (patch/minor updates only)
npm audit fix

# Review what --force would change before applying
npm audit fix --dry-run

Avoid npm audit fix --force in production codebases. Force-fixes can introduce breaking changes by jumping major versions. Instead, review the advisory, understand which versions are patched, and update manually with testing.

Parse audit results programmatically when you need to enforce severity thresholds in CI:

const { execSync } = require('child_process');

function runAuditCheck({ maxSeverity = 'moderate' } = {}) {
  const severityOrder = ['low', 'moderate', 'high', 'critical'];
  const threshold = severityOrder.indexOf(maxSeverity);

  let result;
  try {
    result = JSON.parse(execSync('npm audit --json', { encoding: 'utf-8' }));
  } catch (err) {
    // npm audit exits non-zero when vulnerabilities are found;
    // the JSON is still in stdout
    result = JSON.parse(err.stdout || '{}');
  }

  const vulns = result.vulnerabilities ?? {};
  const violations = Object.entries(vulns).filter(([, details]) => {
    const level = severityOrder.indexOf(details.severity);
    return level >= threshold;
  });

  return {
    passed: violations.length === 0,
    violations: violations.map(([pkg, details]) => ({
      package: pkg,
      severity: details.severity,
      fixAvailable: !!details.fixAvailable,
      via: Array.isArray(details.via) ? details.via.map(v => v?.title ?? v).join(', ') : ''
    }))
  };
}

// Fail CI if any high or critical vulnerabilities are found
const { passed, violations } = runAuditCheck({ maxSeverity: 'high' });
if (!passed) {
  console.error('Security violations found:');
  violations.forEach(v => {
    console.error(`  ${v.package} (${v.severity}): ${v.via}`);
    console.error(`  Fix available: ${v.fixAvailable}`);
  });
  process.exit(1);
}

Snyk: Deeper Analysis

Snyk goes beyond npm audit in two important ways. First, it has its own vulnerability database with different (often more complete) coverage — some vulnerabilities appear in Snyk’s database before they reach npm’s advisories. Second, Snyk can detect license compliance issues and provide fix PRs automatically.

Install and authenticate once per developer machine:

npm install -g snyk
snyk auth

The main commands you’ll use daily:

# Test current project (exits non-zero on issues)
snyk test

# JSON output for CI integration
snyk test --json

# Only fail on high/critical
snyk test --severity-threshold=high

# Register project with Snyk dashboard for continuous monitoring
snyk monitor

snyk monitor is worth calling from your deployment pipeline. It takes a snapshot of your dependency tree and watches for new vulnerabilities discovered after your deployment — useful for catching issues in dependencies you’ve already shipped.

Integrate Snyk as a step in your GitHub Actions workflow:

# .github/workflows/security.yml
name: Security Scan

on:
  push:
    branches: [main, develop]
  pull_request:
    branches: [main]
  schedule:
    - cron: '0 8 * * 1'  # Weekly Monday morning scan

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Setup Node.js
        uses: actions/setup-node@v4
        with:
          node-version: '20'
          cache: 'npm'

      - name: Install dependencies
        run: npm ci

      - name: npm audit (block critical)
        run: npm audit --audit-level=high

      - name: Snyk vulnerability scan
        uses: snyk/actions/node@master
        env:
          SNYK_TOKEN: ${{ secrets.SNYK_TOKEN }}
        with:
          args: --severity-threshold=high

The scheduled scan on Mondays catches vulnerabilities disclosed since your last code push — important because your dependencies don’t change, but the threat landscape does.

Dependency Pinning Strategy

There are three version specifier styles in package.json:

{
  "dependencies": {
    "express": "4.19.2",      // Exact pin — reproducible
    "lodash": "^4.17.21",     // Caret — minor/patch updates allowed
    "chalk": "~5.3.0"         // Tilde — patch updates only
  }
}

The tradeoff is stability vs. automatic security patches. Caret (^) gives you patch security fixes automatically but risks accidental breaking changes. Exact pins give you full reproducibility but mean you must manually apply security fixes.

The pragmatic approach most production teams use:

  • Pin runtime dependencies exactly for predictability
  • Use package-lock.json or npm ci for reproducible installs across environments
  • Automate version bump PRs with Dependabot or Renovate — they open small, reviewable PRs rather than surprise updates

Configure Dependabot to watch your dependencies:

# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: npm
    directory: /
    schedule:
      interval: weekly
    open-pull-requests-limit: 10
    ignore:
      - dependency-name: "*"
        update-types: ["version-update:semver-major"]

This opens weekly PRs for minor and patch updates. Major version bumps require a separate, intentional upgrade since they often have breaking changes.

Supply Chain Attacks

A supply chain attack happens when an attacker compromises a package you depend on rather than attacking your code directly. Typical attack vectors:

  • Typosquatting — publishing lodahs or crossenv hoping developers mistype
  • Maintainer account takeover — phishing or credential stuffing to gain publish access
  • Dependency confusion — exploiting how npm resolves scoped vs. unscoped packages in mixed registries
  • Malicious updates — a maintainer deliberately adding malicious code (the event-stream incident)

Defenses at the npm configuration level:

# Lock your registry to the official npm registry
npm config set registry https://registry.npmjs.org/

# Enable 2FA for publish operations on your account
npm profile enable-2fa auth-and-writes

# Check a package's reputation before installing
npm view suspicious-package dist-tags maintainers time

Before installing an unfamiliar package, check three things: download volume (high-traffic packages are more scrutinized), maintainer count (solo maintainer packages are higher risk), and publish date (recently created packages deserve extra scrutiny).

// Quick reputation check against the npm registry API
async function checkPackageReputation(packageName) {
  const response = await fetch(`https://registry.npmjs.org/${encodeURIComponent(packageName)}`);
  if (!response.ok) {
    return { exists: false };
  }

  const data = await response.json();
  const versionCount = Object.keys(data.versions ?? {}).length;
  const maintainerCount = data.maintainers?.length ?? 0;
  const lastPublished = data.time?.modified;
  const daysSincePublish = lastPublished
    ? (Date.now() - new Date(lastPublished)) / (1000 * 60 * 60 * 24)
    : Infinity;

  const warnings = [];
  if (versionCount < 3) warnings.push('Very few published versions');
  if (maintainerCount === 1) warnings.push('Single maintainer');
  if (daysSincePublish < 30) warnings.push('Published or updated within last 30 days');

  return { packageName, versionCount, maintainerCount, lastPublished, warnings };
}

Validating Your npm Token Hygiene

Token leakage is a common way attackers get publish access. Audit your published tokens periodically:

# List your active tokens
npm token list

# Revoke a token by ID
npm token revoke <token-id>

# Create a read-only token for CI (cannot publish)
npm token create --read-only

# Create a CIDR-restricted token
npm token create --cidr=203.0.113.0/24

Use read-only tokens in CI pipelines. Reserve publish-capable tokens for your CD pipeline, scoped to your deployment environment only.

Integrating Checks into Development Workflow

The best security process is one developers don’t have to think about. A prepare or postinstall hook ensures checks run automatically:

{
  "scripts": {
    "postinstall": "npm audit --audit-level=high",
    "security:check": "npm audit --json | node scripts/audit-check.js",
    "security:snyk": "snyk test --severity-threshold=high",
    "security:full": "npm run security:check && npm run security:snyk"
  }
}

Keep CI feedback fast by caching the npm audit results and only re-running when package-lock.json changes:

- name: Cache audit results
  uses: actions/cache@v4
  with:
    path: .npm-audit-cache
    key: audit-${{ hashFiles('package-lock.json') }}

- name: Run security checks
  run: npm run security:full

Summary

Dependency security is a process, not a one-time fix. The core habits:

  • Run npm audit on every install and in CI; block on high/critical
  • Use Snyk for deeper scanning and continuous monitoring of deployed dependencies
  • Pin exact versions for production, use Dependabot to automate patch updates
  • Scrutinize new packages before adding them — check maintainers, age, and download volume
  • Use read-only npm tokens in CI, restrict publish tokens by environment
  • Keep package-lock.json committed and use npm ci in CI for reproducible installs

Resources

Comments

👍 Was this article helpful?