SPDX vs CycloneDX for Frontend SBOMs

Permalink to "SPDX vs CycloneDX for Frontend SBOMs"

Part of Automated SBOM Generation, this page puts the two dominant bill-of-materials formats side by side for a JavaScript build and gives a defensible rule for choosing one — or emitting both.

Quick Reference

Permalink to "Quick Reference"
Dimension SPDX CycloneDX
Steward Linux Foundation OWASP
Standard ISO/IEC 5962:2021 (SPDX 2.2.1) ECMA-424 (CycloneDX 1.6)
Current versions 2.3 in practice, 3.0 published 1.6
Design origin Licence compliance Application security
Unit of inventory packages (plus files) components
Internal identifier SPDXID (SPDXRef-…) bom-ref (free-form)
Graph model relationships[], 40+ types dependencies[], dependsOn
purl Via externalRefs First-class purl field
Digest field checksums[].checksumValue (hex) hashes[].content (hex)
SHA-384 label SHA384 SHA-384
Vulnerabilities Not modelled in 2.3 Native vulnerabilities[]
VEX External (OpenVEX, CSAF) Built into the same schema
Serialisations JSON, YAML, RDF, tag-value JSON, XML, Protobuf

The mental model

Permalink to "The mental model"

The two formats answer different original questions, and almost every structural difference follows from that. SPDX began inside the Linux Foundation as a way to answer what licences am I redistributing? — a question that demands per-file granularity, declared-versus-concluded licence fields, copyright text, and an audit trail of who analysed what. CycloneDX began inside OWASP as a way to answer what could hurt me? — a question that demands a compact component list, stable identifiers you can join against advisory feeds, and somewhere to record that a flagged CVE does not actually apply.

Neither format is a security product or a compliance product on its own. Both are envelopes. What each one makes cheap is the tell: SPDX makes it cheap to express nuanced provenance and licence conclusions about a tree of files; CycloneDX makes it cheap to express a dependency graph annotated with vulnerabilities and their exploitability. Ask which of those two sentences describes the conversation you are about to have with the document’s reader, and the choice usually collapses to one answer.

It is worth being precise about what neither format standardises. Neither one specifies how a tool should discover components, so two generators pointed at the same repository will disagree about optional dependencies, bundled code and dev-only tooling. Neither one certifies that the digest recorded next to a component is the digest of the bytes you actually shipped — that binding is something your pipeline has to create, usually by hashing post-build artifacts as described in Generating CycloneDX SBOMs for Frontend Assets. A schema validator that says “valid” is telling you the JSON is well-shaped, nothing more.

Document structure and identifiers

Permalink to "Document structure and identifiers"

An SPDX 2.3 JSON document is a header (spdxVersion, dataLicense: CC0-1.0, a documentNamespace URI that must be unique per document, and creationInfo), an array of packages, an optional array of files, and a separate relationships array that wires everything together. The graph is expressed out-of-line: a relationship names a subject spdxElementId, a relationshipType such as DESCRIBES, DEPENDS_ON, CONTAINS or DEV_DEPENDENCY_OF, and a relatedSpdxElement. Every element carries an SPDXID matching SPDXRef- plus letters, digits, dots and hyphens, and those identifiers are meaningful only inside the document that defines them.

A CycloneDX 1.6 document is a header (bomFormat, specVersion, a serialNumber in urn:uuid form, and an integer version that increments when you re-issue the same BOM), a metadata block naming the thing being described, a flat components array, and a dependencies array that is a plain adjacency list of ref to dependsOn. The bom-ref is a free-form string with one rule — unique within the document — and the near-universal convention is to use the package URL itself, which makes the graph readable without a lookup table.

SPDX and CycloneDX document anatomy Two parallel columns. The SPDX column shows a header with creationInfo and documentNamespace, a packages array keyed by SPDXID, and a separate relationships array. The CycloneDX column shows a header with bomFormat and serialNumber, a components array keyed by bom-ref, and a dependencies adjacency list. A footer notes that SPDX carries purl in externalRefs while CycloneDX has a first-class purl field. SPDX 2.3 document CycloneDX 1.6 BOM dataLicense: CC0-1.0 documentNamespace, creationInfo packages[] SPDXID, name, versionInfo licenseConcluded, checksums[] relationships[] spdxElementId + DEPENDS_ON 40+ relationship types purl rides in externalRefs bomFormat, specVersion: 1.6 serialNumber (urn:uuid), metadata components[] bom-ref, name, version, purl licenses[], hashes[] dependencies[] ref + dependsOn[] flat adjacency list purl is a first-class field

Both formats speak Package URL, which is what makes them interoperable at all. A purl such as pkg:npm/[email protected] is the join key that advisory databases, registry proxies and lockfile tooling all agree on, so any lossy conversion between the formats still keeps the one field that matters most. The difference is ergonomic rather than semantic: CycloneDX gives purl a dedicated slot, while SPDX nests it inside an externalRefs entry with referenceCategory: PACKAGE-MANAGER and referenceType: purl. Tools that read SPDX carelessly miss it.

Canonical example: the same dependency in both formats

Permalink to "Canonical example: the same dependency in both formats"

Here is one production dependency, [email protected], expressed in each format with the fields a real consumer will look at. First CycloneDX:

{
  "bomFormat": "CycloneDX",
  "specVersion": "1.6",
  "serialNumber": "urn:uuid:3e671687-395b-41f5-a30f-a58921a69b79",
  "version": 1,
  "components": [
    {
      "type": "library",
      "bom-ref": "pkg:npm/[email protected]",
      "name": "lodash",
      "version": "4.17.21",
      "purl": "pkg:npm/[email protected]",
      "licenses": [{ "license": { "id": "MIT" } }],
      "hashes": [
        {
          "alg": "SHA-384",
          "content": "ed9f7d04f9fe79a82b2fd04c45bde3357307d04c75aee2e02f1099374ddf1dac8d63db7491ac4068cb82c5c63e97f1e1"
        }
      ]
    }
  ],
  "dependencies": [
    { "ref": "pkg:npm/[email protected]", "dependsOn": ["pkg:npm/[email protected]"] }
  ]
}

The same package in SPDX 2.3:

{
  "spdxVersion": "SPDX-2.3",
  "dataLicense": "CC0-1.0",
  "SPDXID": "SPDXRef-DOCUMENT",
  "name": "my-app-1.0.0",
  "documentNamespace": "https://example.com/spdx/my-app-1.0.0-3e671687",
  "creationInfo": {
    "created": "2026-08-05T09:14:00Z",
    "creators": ["Tool: syft-1.20.0", "Organization: Example Ltd"]
  },
  "packages": [
    {
      "SPDXID": "SPDXRef-Package-npm-lodash-4.17.21",
      "name": "lodash",
      "versionInfo": "4.17.21",
      "downloadLocation": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
      "filesAnalyzed": false,
      "licenseConcluded": "MIT",
      "licenseDeclared": "MIT",
      "copyrightText": "NOASSERTION",
      "checksums": [
        {
          "algorithm": "SHA384",
          "checksumValue": "ed9f7d04f9fe79a82b2fd04c45bde3357307d04c75aee2e02f1099374ddf1dac8d63db7491ac4068cb82c5c63e97f1e1"
        }
      ],
      "externalRefs": [
        {
          "referenceCategory": "PACKAGE-MANAGER",
          "referenceType": "purl",
          "referenceLocator": "pkg:npm/[email protected]"
        }
      ]
    }
  ],
  "relationships": [
    {
      "spdxElementId": "SPDXRef-DOCUMENT",
      "relatedSpdxElement": "SPDXRef-Package-npm-my-app-1.0.0",
      "relationshipType": "DESCRIBES"
    },
    {
      "spdxElementId": "SPDXRef-Package-npm-my-app-1.0.0",
      "relatedSpdxElement": "SPDXRef-Package-npm-lodash-4.17.21",
      "relationshipType": "DEPENDS_ON"
    }
  ]
}

Three details in that pair cost teams real time. The algorithm label differs — SHA-384 in CycloneDX, SHA384 in SPDX — and a strict validator rejects the wrong spelling. The digest is hexadecimal in both formats, whereas an integrity attribute wants base64, so a comparison between the two needs a conversion step rather than a string equality check. And the licence sits in one field in CycloneDX but two in SPDX: licenseDeclared is what the package claims, licenseConcluded is what your reviewer decided, and a compliance team cares enormously about the gap between them.

Converting the recorded digest into an attribute a browser will enforce takes one pipeline:

HEX="ed9f7d04f9fe79a82b2fd04c45bde3357307d04c75aee2e02f1099374ddf1dac8d63db7491ac4068cb82c5c63e97f1e1"
printf '%s' "$HEX" | xxd -r -p | openssl base64 -A
# -> 7Z99BPn+eagrL9BMRb3jNXMH0Ex1ruLgLxCZN03fHayNY9t0kaxAaMuCxcY+l/Hh

That base64 string is what ships on the tag:

<script src="https://cdn.example.com/app.9f3c2a.js"
        integrity="sha384-7Z99BPn+eagrL9BMRb3jNXMH0Ex1ruLgLxCZN03fHayNY9t0kaxAaMuCxcY+l/Hh"
        crossorigin="anonymous"></script>

The reverse direction, and the hashing rules behind both, are covered in Generating SRI Hashes with OpenSSL and shasum.

Vulnerability data and VEX

Permalink to "Vulnerability data and VEX"

This is the sharpest functional difference. CycloneDX defines a vulnerabilities array in the same schema as the component list, with each entry carrying a source, ratings, affected bom-ref targets, and an analysis object holding a state (exploitable, not_affected, false_positive, in_triage, resolved) and a justification such as code_not_reachable. That is a Vulnerability Exploitability eXchange record living inside the inventory it annotates, and it can also be published as a standalone VEX document that references the BOM by serial number.

SPDX 2.3 has no vulnerability model at all. The intended pattern is a separate document — OpenVEX or CSAF — that references packages by purl. SPDX 3.0 changes this: its Security profile introduces vulnerability and VEX assessment relationships as first-class model elements. In practice, as of 2026, most generators still emit 2.3 and most consumers still expect it, so the honest position is that SPDX 3.0’s security modelling exists on paper and in early tooling while 2.3 remains what pipelines actually exchange. If exploitability suppression is part of your workflow today, CycloneDX gives it to you without a second document format.

The practical consequence is about noise. An SBOM without VEX turns every transitive CVE into a ticket, which is the same failure mode described in Triaging npm audit Findings. Being able to write not_affected with a justification, in the same file, is what stops a frontend team from re-litigating the same prototype-pollution advisory in a build-only package every sprint.

Tooling in the JavaScript ecosystem

Permalink to "Tooling in the JavaScript ecosystem"

npm ships an SBOM generator in the CLI itself, and it is the only tool here that emits both formats from a single implementation:

npm ci --ignore-scripts

npm sbom --sbom-format=cyclonedx --sbom-type=application > sbom.cdx.json
npm sbom --sbom-format=spdx --omit=dev > sbom.spdx.json

npm sbom reads the installed tree, so it fails if dependencies have not been installed — run npm ci first, and remember that the resolution rules it inherits are the ones covered in Parsing package-lock.json for Dependency Audits. Syft is the other dual-format option, and it can write both documents from one filesystem scan:

syft scan dir:. \
  -o cyclonedx-json=sbom.cdx.json \
  -o spdx-json=sbom.spdx.json

Because both outputs come from the same catalogue pass, the two files describe an identical component set — which is exactly what you want when a security team and a procurement team are reading different files about the same release. The CycloneDX-native tools go deeper but only in one direction: @cyclonedx/cyclonedx-npm reads the lockfile directly and resolves transitive npm dependencies more faithfully than a filesystem scan, and @cyclonedx/cdxgen adds framework detection and evidence capture:

npx --yes @cyclonedx/cdxgen -t javascript -o sbom.cdx.json

On the SPDX side the equivalent depth comes from ecosystem-agnostic scanners rather than npm-aware ones. For validation, the Python spdx-tools distribution provides pyspdxtools, and ntia-conformance-checker reports whether a document carries the minimum data elements procurement teams ask for:

pip install spdx-tools
pyspdxtools -i sbom.spdx.json
# no output and exit status 0 means the document parsed and validated

How each format is consumed downstream

Permalink to "How each format is consumed downstream"

Choose by the reader, not by the writer. Dependency-Track ingests CycloneDX natively and uses the vulnerabilities and analysis fields for suppression; its v4 API does not accept SPDX, so an SPDX-only pipeline needs a conversion step before it can feed the platform that most frontend teams use for continuous monitoring. GitHub sits on the other side: its dependency graph exports SPDX 2.3, retrievable per repository, which makes SPDX the path of least resistance if your inventory of record is GitHub itself.

gh api /repos/OWNER/REPO/dependency-graph/sbom | jq '.sbom.spdxVersion, (.sbom.packages | length)'
# -> "SPDX-2.3"
# -> 412

Note that GitHub’s dependency submission API is a separate thing again: it accepts GitHub’s own snapshot schema rather than either SBOM format, and the actions that bridge SPDX or CycloneDX into it are community-maintained translators. Procurement is the third audience, and it is the least automated — a customer security questionnaire usually asks for “an SBOM in SPDX or CycloneDX format” and a human opens it once. There, the ISO number attached to SPDX carries weight out of proportion to any technical merit.

One resolved tree, two formats, four consumers A resolved package-lock.json feeds a generator such as syft or npm sbom, which emits a CycloneDX document consumed by Dependency-Track and policy gates, and an SPDX document consumed by the GitHub dependency graph and procurement questionnaires. package-lock resolved tree syft scan or npm sbom CycloneDX 1.6 ECMA-424 SPDX 2.3 ISO/IEC 5962 Dependency-Track VEX suppression release policy gates in CI GitHub dep graph SPDX export procurement questionnaires

Size is the last practical dimension. SPDX requires or strongly expects fields that CycloneDX treats as optional — downloadLocation, filesAnalyzed, copyrightText, both licence fields — and where a value is unknown the convention is to write the literal string NOASSERTION rather than omit the key. Add the out-of-line relationships array, which spends a whole object on each edge that CycloneDX expresses as one string in a list, and an SPDX document for the same npm tree typically lands somewhere between one and a half and two times the size of its CycloneDX counterpart. That rarely matters for a few hundred packages; it starts to matter when you store a document per commit for a year.

Variants: three ways to ship

Permalink to "Variants: three ways to ship"

The decision has three stable outcomes, and the order in which you ask the questions matters more than the answers.

Choosing an SBOM format A decision tree. If a contract names a format, emit that format. Otherwise ask who consumes the document: a security tool leads to CycloneDX, a licence or procurement review leads to SPDX, and mixed audiences lead to generating both from a single scan. Contract names a format? yes Emit the named format nothing else is negotiable no Who consumes the document? security CycloneDX 1.6 VEX in the same file licence SPDX 2.3 declared vs concluded both Emit both from a single scan pass identical component set, two serialisations

Ship CycloneDX only

Permalink to "Ship CycloneDX only"

Right when the document’s whole purpose is vulnerability management: continuous monitoring in Dependency-Track, exploitability suppression, or a CI gate that blocks a release on a severity threshold. You get VEX in the same artifact, the richest npm-aware tooling, and a spec that adds security-oriented fields faster. The cost is that a procurement reviewer may ask for an ISO-numbered format you then have to produce under time pressure.

Ship SPDX only

Permalink to "Ship SPDX only"

Right when licence obligations are the deliverable — an open-source review board, a redistribution audit, or a public-sector contract that cites ISO/IEC 5962. The licenseDeclared versus licenseConcluded split and the relationship vocabulary are genuinely better tools for that job than anything CycloneDX offers. The cost is that your security platform probably cannot read it without a lossy conversion.

Ship both from one scan

Permalink to "Ship both from one scan"

The default for any team with more than one audience. One scan, two output flags, both files attached to the same release:

# .github/workflows/sbom.yml (excerpt)
- name: Generate both SBOM formats
  run: |
    syft scan dir:. \
      -o cyclonedx-json=sbom.cdx.json \
      -o spdx-json=sbom.spdx.json

- name: Attach to release
  uses: actions/upload-artifact@v4
  with:
    name: sboms
    path: |
      sbom.cdx.json
      sbom.spdx.json

Generating twice from one catalogue pass beats converting one document into the other, because conversion drops whatever the target format cannot express and leaves you unable to explain why two files disagree.

Gotchas and Edge Cases

Permalink to "Gotchas and Edge Cases"
  • Conversion is lossy in both directions. cyclonedx-cli reads SPDX JSON and will carry names, versions, licences and purls across, but SPDX relationship types other than DEPENDS_ON, file-level records and package verification codes have no CycloneDX equivalent. Going the other way, CycloneDX vulnerabilities and analysis blocks vanish entirely into SPDX 2.3. Always regenerate rather than convert when you control the build.

  • integrity without crossorigin silently disables enforcement. An SBOM digest is only useful if the corresponding tag actually enforces it. A cross-origin <script> or <link> carrying integrity but no crossorigin="anonymous" is fetched in no-cors mode, the response is opaque, and the browser cannot check the hash — so the request fails or, worse, the tag never blocks anything. Every example on this page carries both attributes for that reason.

  • Neither format standardises component discovery. npm sbom, syft and @cyclonedx/cyclonedx-npm pointed at the same repository routinely produce different component counts because they disagree about optional dependencies, workspace links and bundled code. Pin the generator and its version in CI and review count changes as a diff, or your “SPDX vs CycloneDX” difference will actually be a tool difference.

  • NOASSERTION is not the same as absent. SPDX consumers treat a missing licenseConcluded as a schema problem and a NOASSERTION value as an honest unknown. Stripping those placeholders to shrink the file breaks conformance checks that procurement teams run.

  • A valid document proves nothing about the shipped bytes. Schema validation checks structure only. Binding the inventory to the artifacts requires hashing post-build output and, ideally, signing the result — the attestation half of the problem covered in Verifying Sigstore Provenance for npm Packages.

Verification Steps

Permalink to "Verification Steps"

1. Confirm each document reports the version you expect

Permalink to "1. Confirm each document reports the version you expect"
jq -r '.specVersion' sbom.cdx.json
jq -r '.spdxVersion' sbom.spdx.json

Expected output:

1.6
SPDX-2.3

2. Compare component counts across the two files

Permalink to "2. Compare component counts across the two files"
echo "cyclonedx: $(jq '.components | length' sbom.cdx.json)"
echo "spdx:      $(jq '[.packages[] | select(.SPDXID != "SPDXRef-DOCUMENT")] | length' sbom.spdx.json)"

Counts from a single syft pass should match within one — SPDX usually includes a package entry describing the application itself. A larger gap means the two files came from different scans or different flags.

3. Check that purls survived on both sides

Permalink to "3. Check that purls survived on both sides"
jq -r '[.components[] | select(.purl == null) | .name] | length' sbom.cdx.json
jq -r '[.packages[] | select((.externalRefs // []) | map(.referenceType == "purl") | any | not) | .name] | length' sbom.spdx.json

Both should print 0. A non-zero count on the SPDX side is the classic symptom of a generator that filled in name and versionInfo but skipped externalRefs, leaving downstream matchers with nothing to join on.

4. Validate each document against its schema

Permalink to "4. Validate each document against its schema"
npx --yes @cyclonedx/cyclonedx-library validate --input-file sbom.cdx.json --fail-on-errors
pyspdxtools -i sbom.spdx.json

A zero exit status from both is the gate. Wire the pair into the same CI job that builds the release artifact so a malformed document fails the build rather than the customer’s ingestion pipeline.

Frequently Asked Questions

Permalink to "Frequently Asked Questions"
Is one of the two formats an official international standard?

Both are, by different routes. SPDX 2.2.1 was published as ISO/IEC 5962:2021, which is why public-sector and enterprise procurement documents tend to name it. CycloneDX 1.6 was adopted by Ecma International as ECMA-424 in 2024, alongside its home as an OWASP project. Neither status makes a document correct — a validator only checks structure, never whether the component list matches what you shipped.

Can I convert an SPDX document into CycloneDX?

Partially. cyclonedx-cli accepts SPDX JSON as an input format and will map package names, versions, licences and purls across. Anything without a counterpart is dropped: SPDX relationship types beyond DEPENDS_ON, file-level records, and verification codes have nowhere to go. Generating both documents from one scan is more faithful than converting one into the other.

Which format should I upload to Dependency-Track?

CycloneDX. Dependency-Track ingests CycloneDX documents natively and reads the vulnerabilities and analysis fields for VEX suppression. SPDX input is not supported by the v4 API, so an SPDX-only pipeline needs a conversion step that loses the parts Dependency-Track most wants.

Do the hashes in an SBOM match my integrity attribute?

Not without conversion. Both formats record digests as hexadecimal strings, while an integrity attribute carries the same digest base64-encoded behind a sha384- prefix. Decode the hex to raw bytes and re-encode as base64 before comparing. The algorithm label differs too: CycloneDX writes SHA-384 with a hyphen, SPDX writes SHA384 without one.

Does either format guarantee two tools produce the same component list?

No. Both specifications standardise the document, not the discovery process. A lockfile reader, a node_modules scanner and a bundler plugin will disagree about optional dependencies, dev-only packages and bundled code regardless of which format they serialise to. Pin the generator and its version in CI, and treat a change in component count as a reviewable diff.

Permalink to "Related"

Related Articles

Generating CycloneDX SBOMs for Frontend Assets
Automated SBOM Generation Supply Chain Auditing & Depend…