Safeguard
Supply Chain Security•

Generate a CycloneDX or SPDX SBOM With npm sbom, Then Scan It

npm has a built-in sbom command. We ran it on a 23 package project in both formats, inspected the output, and fed it to a scanner. Commands and real results.

Safeguard Security Team
5 min read

You do not need a separate tool to produce an SBOM for a Node project. The npm CLI has had a built-in npm sbom command for some time, and with npm 12.2.0 published on 30 September 2026 it is part of the current release. This guide runs it on a real project in both formats, shows what ends up in the file, and then scans the result.

The test project depends on @fastify/jwt 10.2.0 with a lockfile resolved as of 29 July 2026, giving 23 packages in total. One of them, fast-jwt 6.3.0, has known advisories, which makes the scan at the end worth reading.

What's affected

Anyone who needs an SBOM from an npm project: for a customer request, a procurement questionnaire, a compliance artifact or your own inventory. The command reads the dependency tree and writes either CycloneDX or SPDX JSON to standard output.

The docs list these options:

OptionValuesDefault
sbom-formatcyclonedx or spdxnone, you must choose
sbom-typelibrary, application, frameworklibrary
omitdev, optional, peerdev when NODE_ENV is production
package-lock-onlytrue or falsefalse
workspace / workspacesnames, paths, or truenone

How to check if you're exposed

First build the project, so the tree is what you ship:

mkdir demo && cd demo
echo '{"name":"demo","version":"1.0.0","dependencies":{"@fastify/jwt":"10.2.0"}}' > package.json
npm install --before=2026-07-29 --ignore-scripts

Generate both formats:

npm sbom --sbom-format cyclonedx > sbom.cdx.json
npm sbom --sbom-format spdx > sbom.spdx.json

What we got:

  • CycloneDX: specVersion 1.5, 23 components and 24 entries in the dependency graph (the extra one is your own project).
  • SPDX: spdxVersion SPDX-2.3, 24 packages, again including the root.
  • Each CycloneDX component has a package URL, for example pkg:npm/%40fastify/jwt@10.2.0, plus fields for author, description, hashes and licenses.
  • In SPDX the same package appears as SPDXRef-Package-fast-jwt-6.3.0 with versionInfo 6.3.0.

Check one entry:

jq '.components[] | select(.name == "fast-jwt") | {name, version, purl}' sbom.cdx.json
{ "name": "fast-jwt", "version": "6.3.0", "purl": "pkg:npm/fast-jwt@6.3.0" }

Now scan it with OSV-Scanner:

osv-scanner scan --sbom sbom.cdx.json

It reported two advisories on fast-jwt 6.3.0: GHSA-8wpc-h4q6-8fxv, fixed in 6.3.1, and GHSA-x937-hj6v-793p, fixed in 6.3.4. This is the same result as scanning the lockfile directly, which confirms the SBOM carries enough information to be useful.

The two formats side by side

Both files describe the same 23 packages. The differences are in how they say it:

AspectCycloneDX (from npm)SPDX (from npm)
Version of the spec1.5SPDX-2.3
Package listcomponents array, 23 entriespackages array, 24 entries including the root
Package identitypurl such as pkg:npm/fast-jwt@6.3.0SPDXID such as SPDXRef-Package-fast-jwt-6.3.0 plus versionInfo
Dependency graphdependencies array, 24 entriesrelationships section
Root projectmetadata.componentthe first package, named demo

For vulnerability matching, the package URL is the useful field. Scanners match a purl against advisory data without guessing at names, which is why the CycloneDX output scanned correctly. The npm docs note that the examples on their page were generated by CLI 10.1.0, so field details can shift between releases. Check your own output rather than copying from a page.

What to do now

  1. Choose the format your recipient asks for. CycloneDX and SPDX both work, and you can generate both in seconds.
  2. Set --sbom-type application for something you deploy, rather than the default library. In our run the root component's type changed from library to application.
  3. Use --omit dev for a production SBOM, so test tools do not appear as shipped components.
  4. Use --package-lock-only when you have no node_modules, such as in a lightweight CI job. The docs warn that only lockfile data is loaded, so details like description and homepage from each package.json are missing. Our run still listed 23 components.
  5. Generate the SBOM at build time and store it with the release artifact, not weeks later from a different tree.
  6. Scan it and attach the result. If you have fixes pending, a VEX document can explain which findings do not apply.
  7. Refresh it on every release. An SBOM describes one build.

Why it matters

An SBOM is a snapshot of one resolved tree. The one above is accurate for a project built against a July lockfile, and it would be wrong for the same project built today. That is why the date of generation and the lockfile used belong next to the file.

The SBOM also says nothing about reachability. It lists fast-jwt 6.3.0 whether or not your code ever calls the vulnerable verifier setup. Use it as the inventory, then ask which entries your code actually reaches.

Try it yourself

Run the two generate commands in one of your own projects and compare the component counts to npm ls --all. Differences usually come from dev dependencies and optional packages. Then drop the CycloneDX file into the free scanner at gold.safeguard.sh/scan. It runs in your browser, nothing leaves your machine, and it accepts CycloneDX and SPDX files of up to 100 dependencies.

Click through it at your own pace, or open it full screen.

Quick check

Why does the SPDX file have 24 packages for a project with 23 dependencies?

The extra package is the root project itself, which SPDX lists alongside its dependencies.

Which flag keeps dev dependencies out of a production SBOM?

--omit dev.

Sources

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.

Self-healing security runs on Safeguard.

Your first fix PR is minutes away.

No sales call required, even your agent can complete the purchase over MCP.