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:
| Option | Values | Default |
|---|---|---|
sbom-format | cyclonedx or spdx | none, you must choose |
sbom-type | library, application, framework | library |
omit | dev, optional, peer | dev when NODE_ENV is production |
package-lock-only | true or false | false |
workspace / workspaces | names, paths, or true | none |
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:
specVersion1.5, 23 components and 24 entries in the dependency graph (the extra one is your own project). - SPDX:
spdxVersionSPDX-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.0withversionInfo6.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:
| Aspect | CycloneDX (from npm) | SPDX (from npm) |
|---|---|---|
| Version of the spec | 1.5 | SPDX-2.3 |
| Package list | components array, 23 entries | packages array, 24 entries including the root |
| Package identity | purl such as pkg:npm/fast-jwt@6.3.0 | SPDXID such as SPDXRef-Package-fast-jwt-6.3.0 plus versionInfo |
| Dependency graph | dependencies array, 24 entries | relationships section |
| Root project | metadata.component | the 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
- Choose the format your recipient asks for. CycloneDX and SPDX both work, and you can generate both in seconds.
- Set
--sbom-type applicationfor something you deploy, rather than the defaultlibrary. In our run the root component's type changed fromlibrarytoapplication. - Use
--omit devfor a production SBOM, so test tools do not appear as shipped components. - Use
--package-lock-onlywhen 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. - Generate the SBOM at build time and store it with the release artifact, not weeks later from a different tree.
- Scan it and attach the result. If you have fixes pending, a VEX document can explain which findings do not apply.
- 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.