Back to glossary

SPDX

SPDX is the Linux Foundation standard (ISO/IEC 5962) that names software licenses with short IDs like MIT and defines a document format for SBOMs.

Also known as Software Package Data Exchange, SPDX license identifier, SPDX license expression, SPDX License List, SPDX SBOM, ISO/IEC 5962, SPDX-License-Identifier

SPDX (Software Package Data Exchange) is a Linux Foundation standard, published as ISO/IEC 5962:2021, that gives each software license a short identifier such as Apache-2.0, a grammar for combining them, and a document format for software bills of materials (SBOMs). Package managers, scanners, and regulators use SPDX to make license and component data machine-readable.

An SPDX identifier is a claim, not a finding. The string in a package's license field is whatever its author typed, and npm never checks it. In our tests, npm 12.2.1 accepted "license": "Apache 2.0" without a warning, and both npm 12.2.1 and pnpm 12.15.0 copied an invalid BSD into SPDX SBOMs that the official SPDX validator rejects. A valid expression can still be incomplete: next@16.4.3 declares MIT but ships Mozilla Public License 2.0 code that no entry in its SBOM mentions. Our position: use SPDX as the vocabulary for license data, and get the facts from the files inside the package.

What is SPDX?

#

SPDX is two artifacts maintained by one Linux Foundation project: the SPDX License List and the SPDX specification. Drafting began in February 2010, and SPDX 1.0 shipped in August 2011 (SPDX). SPDX 2.2.1 became the international standard ISO/IEC 5962:2021 in August 2021. SPDX 3.0 followed in April 2024. As of October 2026, the latest final release is SPDX 3.0.1 from December 2024, and SPDX 3.1 has been a release candidate since January 2026 (spdx-spec releases).

The License List is the part nearly every developer uses without noticing. Every "license": "MIT" in a package.json, every License-Expression: line in Python wheel metadata, and every license = "MIT OR Apache-2.0" in a Cargo.toml is an SPDX license expression.

The specification is the part SBOM tools use. It defines documents that describe packages, files, and snippets, the relationships between them, and the license and copyright data attached to each. SPDX 2.3 documents can be JSON, YAML, tag-value, RDF/XML, or spreadsheets. SPDX 3.0 rebuilt the model as a graph of elements and typed relationships, serialized as JSON-LD and split into profiles such as Software, Security, Build, AI, and two licensing profiles. Our CycloneDX entry compares the two SBOM formats field by field and gives our verdict on when to use which. This page focuses on the license half, because that is where SPDX is already everywhere.

What are SPDX license identifiers?

#

SPDX license identifiers are short, stable names for license texts, such as MIT, Apache-2.0, and GPL-2.0-only. As of October 2026, the current SPDX License List is version 3.29.0, released September 16, 2026. It defines 740 license identifiers, 32 of them deprecated, and 86 exception identifiers such as Classpath-exception-2.0 (license-list-data). This is the machine-readable entry behind one identifier:

JSON
{
  "reference": "https://spdx.org/licenses/Apache-2.0.html",
  "isDeprecatedLicenseId": false,
  "detailsUrl": "https://spdx.org/licenses/Apache-2.0.json",
  "name": "Apache License 2.0",
  "licenseId": "Apache-2.0",
  "seeAlso": [
    "https://www.apache.org/licenses/LICENSE-2.0",
    "https://opensource.org/license/apache-2.0"
  ],
  "isOsiApproved": true,
  "isFsfLibre": true
}

An identifier names a canonical license text. The list also publishes matching guidelines that define which differences, such as copyright lines and whitespace, still count as the same license. Real LICENSE files stray further than the guidelines anticipate. When we built Socket's license scanner, we got significantly better accuracy by replacing the SPDX matching guidelines with normalization rules tuned on files observed in public registries.

The same identifiers appear in source files as SPDX-License-Identifier: comments. The Linux kernel made them mandatory. Its licensing rules say: "The Linux kernel requires the precise SPDX identifier in all source files" (kernel docs).

C
// SPDX-License-Identifier: GPL-2.0
// SPDX-License-Identifier: GPL-2.0+ WITH GCC-exception-2.0
// SPDX-License-Identifier: GPL-2.0 OR BSD-3-Clause

A deprecated SPDX identifier is still a valid one. Version 3.0 of the License List deprecated GPL-2.0 in favor of GPL-2.0-only and GPL-2.0-or-later, so the identifier itself states whether the "any later version" option applies. The kernel's rules still tell contributors to write GPL-2.0 for version 2 only. npm's own package.json documentation uses (ISC OR GPL-3.0) as its example of a multi-license expression, with a deprecated identifier. A license tool that recognizes only current identifiers misreads both.

Two strings common in npm are not SPDX at all: UNLICENSED and SEE LICENSE IN <file>. UNLICENSED is an npm convention meaning no rights are granted, while Unlicense is the SPDX identifier for a public-domain dedication. UNLICENSED and Unlicense differ by one letter and mean opposite things. The SPDX way to name a license that is not on the list is a LicenseRef- identifier, such as LicenseRef-Acme-EULA, backed by the extracted license text.

How do SPDX license expressions combine licenses?

#

SPDX license expressions combine identifiers with three operators and one suffix. OR offers a choice, AND requires compliance with both sides, WITH attaches an exception to a license, and + means "this version or any later one." The license expression grammar applies them in a fixed order: + first, then WITH, then AND, then OR.

The figure parses the spec's own example, LGPL-2.1-only OR BSD-3-Clause AND MIT. Because AND binds before OR, the expression is LGPL-2.1-only OR (BSD-3-Clause AND MIT). A licensee has two options: comply with LGPL-2.1-only alone, or comply with both BSD-3-Clause and MIT. Reading the operators left to right gives a different and wrong result, (LGPL-2.1-only OR BSD-3-Clause) AND MIT, which would make MIT mandatory in every case. Parentheses override the default order and are the safer way to write any expression that mixes operators.

Both operators show up in real dependency trees. @img/sharp-win32-x64@0.35.5 declares Apache-2.0 AND LGPL-3.0-or-later, because its tarball ships the LGPL-licensed libvips DLLs next to sharp's Apache-licensed binding. That is what an accurate expression for a bundle looks like. node-forge 1.4.0 declares (BSD-3-Clause OR GPL-2.0), so a user may take it under BSD-3-Clause alone. That distinction drives policy. Before Socket raises a copyleft alert, it checks whether some combination of OR choices leaves only permissive licenses, and stays quiet if one does.

In SPDX 3, license expressions are case-insensitive, operators included. Python's packaging library, which PyPI uses to validate uploads, normalizes mit and isc to MIT AND ISC. Expressions can also carry the special values NONE, meaning no license applies, and NOASSERTION, meaning the author of the data did not determine one.

Where SPDX lives in each package ecosystem

#

npm, PyPI, crates.io, and RubyGems all have a manifest field that holds an SPDX expression, but only some of them check what goes in it. We tested the publishing tools directly and read the registry source where we could not:

EcosystemFieldWhat the tooling checks, as of October 2026
npmlicense in package.jsonNothing. npm 12.2.1 ran npm init and npm pack with "Apache 2.0" and printed no warning. The registry accepts invalid strings too: @pm2/js-api@0.8.1, published in June 2026, declares "Apache-2".
PyPILicense-Expression (Core Metadata 2.4)PyPI validates the expression on upload, as PEP 639 requires. The deprecated License field and License :: classifiers are not checked.
crates.iolicense in Cargo.tomlThe registry rejects the publish with "unknown or invalid license expression".
RubyGemslicense in the gemspecgem build 3.5.22 warns "License identifier 'Apache 2.0' is invalid" and suggests Apache-2.0, then builds the gem anyway.

Our verdict: crates.io has the right design, because its publish handler rejects invalid expressions before a crate exists. Validation belongs at the registry, where it applies to every publisher and every tool. A build-time warning is easy to ignore, and npm has neither.

PEP 639 is the most important recent change. It added License-Expression and License-File to Python's core metadata, deprecated the free-text License field and the License :: classifiers, and requires PyPI to "validate that they contain a valid, case-normalized license expression." Adoption is still partial on the Python Package Index, so we measured both ecosystems.

We ran both measurements in October 2026. For npm, we resolved a lockfile for 30 widely used packages, including Next.js, webpack, Jest, ESLint, and the Angular CLI, and fetched the published license field for all 2,843 package versions in the tree. For PyPI, we read the wheel METADATA of the latest release of each of the 500 most downloaded projects on hugovk's top-pypi-packages list (October 1, 2026 snapshot). We validated every value against License List 3.29.0:

What the metadata containsnpm tree (2,843 versions)PyPI top 500
Valid expression with current identifiers, in the dedicated field2,823 (99.3%)228 (45.6%) in License-Expression
Valid expression with a deprecated identifier2 (0.1%)1 (0.2%) in License-Expression
Valid SPDX string in the deprecated License fieldnot applicable130 (26.0%)
Non-SPDX string, such as BSD, Apache 2.0, or Dual License4 (0.1%)78 (15.6%)
Full license text pasted into the field020 (4.0%)
Legacy license objects or arrays9 (0.3%)not applicable
License :: classifiers onlynot applicable42 (8.4%)
Nothing at all5 (0.2%)1 (0.2%)

The npm numbers look near-perfect because the field is simple and MIT dominates: 2,387 of the 2,843 versions, or 84%, declare exactly MIT. The two deprecated values are AGPL-3.0 in pm2@7.1.1 and (BSD-3-Clause OR GPL-2.0) in node-forge@1.4.0. On PyPI, 229 of the top 500 projects, or 45.8%, declare License-Expression. The rest show how slowly metadata migrates. 415 of the 500 latest releases already use Core Metadata 2.4 or later, yet 186 of those still omit License-Expression. Classifiers are also ambiguous by design: jinja2 3.1.6 declares only License :: OSI Approved :: BSD License, which does not say which BSD license. One practical note for anyone repeating this: PyPI's JSON API reported no license_expression for mypy-extensions 1.1.0 and azure-core 1.41.0, although both wheels declare License-Expression: MIT. Read the METADATA file, not the API summary.

Does a valid SPDX expression tell you which licenses apply?

#

A valid SPDX expression does not tell you which licenses apply to the code you ship. It records what the author declared for the package as a whole. Packages bundle, vendor, and compile in other people's code, and the declared expression rarely follows it.

next@16.4.3 is a clean example. Its package.json declares MIT, and npm copies that value into the lockfile entry and then into the SBOM. The tarball holds 8,705 files, 132 of them license files. Most are MIT, but seven contain the Apache License, six contain ISC, and dist/compiled/@vercel/og/ is a vendored copy of @vercel/og 1.0.1 whose own package.json and LICENSE say MPL-2.0. dist/compiled/glob/LICENSE is ISC for the code and adds that glob's logo is licensed under Creative Commons Attribution-ShareAlike 4.0. We generated an SPDX SBOM for an app that depends on next@16.4.3. It lists 55 packages, and not one declares MPL-2.0. A license policy that blocks MPL-2.0, a file-level copyleft license, passes that SBOM.

Bash
npm pack next@16.4.3 && tar xzf next-16.4.3.tgz
jq -r .license package/package.json
# MIT
jq -r '.name + "@" + .version + " " + .license' package/dist/compiled/@vercel/og/package.json
# @vercel/og@1.0.1 MPL-2.0
tail -5 package/dist/compiled/glob/LICENSE
# ## Glob Logo
#
# Glob's logo created by Tanya Brassie <http://tanyabrassie.com/>, licensed
# under a Creative Commons Attribution-ShareAlike 4.0 International License
# https://creativecommons.org/licenses/by-sa/4.0/

Python has the same gap. Django 5.1.1's metadata declares only License: BSD-3-Clause and a generic BSD classifier. Its sdist ships twelve license files, among them the Python license for code taken from the standard library and a test-data notice under Creative Commons Attribution-ShareAlike 3.0. Socket's analysis of Django 5.1.1 found the same class of problem: a copyleft license the registry metadata never exposed. Django 6.1.2 now declares License-Expression: BSD-3-Clause, and its test data has changed to MIT and CC-BY-4.0 sources. The declaration stayed identical while the contents changed. The artifact matters too: the wheel leaves out the tests, so its license picture differs from the sdist's.

None of this is a reason to ignore the declared expression. It is the best signal for one thing: detecting that an author changed the terms. ua-parser-js declares MIT on its 1.x line, which ends at 1.0.41, and AGPL-3.0-or-later from 2.0.0 onward. As of October 2026, its latest tag resolves to 2.0.10. Socket covered the ua-parser-js move from MIT to AGPLv3 before 2.0 shipped. A license check that compares declared expressions on every update catches that change in a pull request. Most such changes arrive through transitive dependencies, where nobody reads release notes.

What npm and pnpm put in an SPDX SBOM

#

SPDX documents record license data in two separate fields, and the difference is the whole point. In SPDX 2.3, licenseDeclared holds what the package authors declared. licenseConcluded holds "the license the SPDX Document creator has concluded as governing the package" (SPDX 2.3, clause 7.13). The spec expects the concluded value to come from analyzing the package's files and scanner results, and says an absent field means NOASSERTION.

We gave both package managers a project with three dependencies: node-forge@1.4.0, is-valid-path@0.1.1, and url-template@2.0.8, which declares BSD. pnpm has shipped a built-in pnpm sbom command since pnpm 11.

Bash
npm install --package-lock-only --ignore-scripts
npx npm@12.2.1 sbom --sbom-format spdx --package-lock-only > npm.spdx.json
npx pnpm@12.15.0 install --ignore-scripts
npx pnpm@12.15.0 sbom --sbom-format spdx > pnpm.spdx.json
pip install spdx-tools==0.8.5
JSON
{
  "spdxVersion": "SPDX-2.3",
  "creationInfo": { "created": "2026-10-25T04:54:40.090Z", "creators": ["Tool: npm/cli-12.2.1"] },
  "packages": [{
    "name": "url-template",
    "versionInfo": "2.0.8",
    "filesAnalyzed": false,
    "licenseDeclared": "BSD",
    "externalRefs": [{ "referenceType": "purl", "referenceLocator": "pkg:npm/url-template@2.0.8" }]
  }]
}

Then we ran both documents through pyspdxtools, the SPDX project's own validator, version 0.8.5:

Plain text
$ pyspdxtools -i npm.spdx.json
ERROR:root:There have been issues while parsing the provided document:
Error while parsing Document: ["time data '2026-10-25T04:54:40.090Z' does not match format '%Y-%m-%dT%H:%M:%SZ'"]

# after trimming the milliseconds
$ pyspdxtools -i npm.spdx.json
ERROR:root:The document is invalid. The following issues have been found:
Unrecognized license reference: BSD. license_expression must only use IDs from the license list or extracted licensing info, but is: BSD

$ pyspdxtools -i pnpm.spdx.json
ERROR:root:The document is invalid. The following issues have been found:
Unrecognized license reference: BSD. license_expression must only use IDs from the license list or extracted licensing info, but is: BSD
Unrecognized license reference: BSD. license_expression must only use IDs from the license list or extracted licensing info, but is: BSD

npm writes a timestamp with milliseconds, which the SPDX 2.3 format YYYY-MM-DDThh:mm:ssZ does not allow, and passes BSD through as if it were an expression. pnpm writes a valid timestamp but makes a more serious error, which is why its document fails twice. It copies every declared license into licenseConcluded while also setting filesAnalyzed: false. That records a conclusion the tool never reached. npm leaves licenseConcluded out, which the spec reads as NOASSERTION, and is the honest answer for a tool that never opened the files.

The fix on the generator side is small. A string that does not parse should become NOASSERTION, or a LicenseRef- with the original text preserved, and never pass through as an expression. On the consumer side, validate every SPDX document before ingesting it. CISA's August 2025 draft of the SBOM minimum elements added license as a minimum element, so these documents are headed for compliance workflows that will trust them.

Declared licenses are not evidence

#

SPDX already has the vocabulary to describe what a package really contains. SPDX 2.3 can record the licenses found in each file. SPDX 3.0 goes further and defines hasDeclaredLicense as a relationship meaning the artifact "was discovered to actually contain" a license, "for example as detected by use of automated tooling" (SPDX 3.0.1 model). Python is moving the same way. PEP 770, final since April 2025, reserves .dist-info/sboms/ inside wheels so a package can describe the native libraries bundled into it.

The tools have not caught up. Both package-manager generators we tested read the license string from the manifest or lockfile and stop there. The grammar of SPDX license expressions is a solved problem. Binding those expressions to the bytes in a package is not, and until generators open the files, an SPDX SBOM is a precise copy of what authors typed into their manifests. License compliance that stops at the manifest is checking the label, not the contents.