# 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

Source: https://socket.dev/glossary/spdx

Last updated: 2026-10-25

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](https://socket.dev/glossary/software-bill-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](https://spdx.dev/about/)). 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](https://github.com/spdx/spdx-spec/releases)).

The License List is the part nearly every developer uses without noticing. Every `"license": "MIT"` in a [package.json](https://socket.dev/glossary/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](https://socket.dev/glossary/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](https://github.com/spdx/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](https://socket.dev/blog/socket-license-analysis) 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](https://github.com/torvalds/linux/blob/master/Documentation/process/license-rules.rst)).

```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](https://github.com/spdx/spdx-spec/blob/support/3.0/docs/annexes/spdx-license-expressions.md) applies them in a fixed order: `+` first, then `WITH`, then `AND`, then `OR`.

> The example expression comes from the SPDX license expression grammar. Parentheses override the default order.
>
> Parse tree for the SPDX license expression LGPL-2.1-only OR BSD-3-Clause AND MIT. AND binds before OR, so the root is OR with two branches: LGPL-2.1-only alone, or BSD-3-Clause AND MIT together. A licensee may pick either branch. Reading left to right instead would wrongly make MIT mandatory.

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](https://socket.dev/blog/introducing-socket-license-detection) 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:

| Ecosystem | Field | What the tooling checks, as of October 2026 |
| --- | --- | --- |
| npm | license in package.json | Nothing. 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". |
| PyPI | License-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.io | license in Cargo.toml | The registry rejects the publish with "unknown or invalid license expression". |
| RubyGems | license in the gemspec | gem 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](https://socket.dev/glossary/crates-io) has the right design, because its [publish handler](https://github.com/rust-lang/crates.io/blob/main/src/controllers/krate/publish.rs) 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](https://peps.python.org/pep-0639/) 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](https://socket.dev/glossary/python-package-index-pypi), 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](https://github.com/hugovk/top-pypi-packages) list (October 1, 2026 snapshot). We validated every value against License List 3.29.0:

| What the metadata contains | npm tree (2,843 versions) | PyPI top 500 |
| --- | --- | --- |
| Valid expression with current identifiers, in the dedicated field | 2,823 (99.3%) | 228 (45.6%) in License-Expression |
| Valid expression with a deprecated identifier | 2 (0.1%) | 1 (0.2%) in License-Expression |
| Valid SPDX string in the deprecated License field | not applicable | 130 (26.0%) |
| Non-SPDX string, such as BSD, Apache 2.0, or Dual License | 4 (0.1%) | 78 (15.6%) |
| Full license text pasted into the field | 0 | 20 (4.0%) |
| Legacy license objects or arrays | 9 (0.3%) | not applicable |
| License :: classifiers only | not applicable | 42 (8.4%) |
| Nothing at all | 5 (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.

> Counts from the published next-16.4.3.tgz tarball and an npm 12.2.1 SPDX SBOM for an app that depends on it, October 2026.
>
> Declared versus shipped licenses for next 16.4.3. The package.json, the lockfile entry, and the npm SPDX SBOM all say MIT. The tarball has 8,705 files, 132 of them license files: mostly MIT, plus 7 files with the Apache License, 6 with ISC, a vendored copy of @vercel/og 1.0.1 under MPL-2.0, and a glob logo notice under CC BY-SA 4.0. The SBOM for an app using next lists 55 packages and none declares MPL-2.0.

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](https://socket.dev/glossary/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](https://socket.dev/blog/socket-license-analysis) 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](https://socket.dev/blog/ua-parser-js-drops-mit-license) 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](https://socket.dev/glossary/transitive-dependency), 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](https://github.com/spdx/spdx-spec/blob/support/2.3/chapters/package-information.md)). 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](https://socket.dev/blog/pnpm-11-adds-new-supply-chain-protection-defaults).

```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:

```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](https://socket.dev/blog/cisa-2025-sbom-guidance-update) 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](https://github.com/spdx/spdx-3-model/blob/3.0.1/model/Core/Vocabularies/RelationshipType.md)). Python is moving the same way. [PEP 770](https://socket.dev/blog/new-python-packaging-proposal-aims-to-solve-phantom-dependency-problem-with-sboms), 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](https://socket.dev/glossary/license-compliance) that stops at the manifest is checking the label, not the contents.
