Quarkus SPDX SBOMs: JSON-LD, Licenses, Real Checks
Build a small Quarkus service, generate an SPDX 3.0.1 SBOM, inspect the JSON-LD graph, and see exactly where NTIA and CISA checks still fail.
I have always been interested in security and software supply chain topics. For a long time that felt like a specialist corner of engineering. Lately it does not. Reported vulnerabilities keep coming, open source projects spend more time on trust and project-health work, and efforts like Project Glassdoor make it harder to pretend dependency risk is somebody else’s problem. If you ship software, somebody will ask for an SBOM. The only real questions are which format they want and what they plan to do with it.
After ./mvnw package, the file that matters in this example is:
target/quarkus-run-spdx.jsonThis is where the article splits from my earlier CycloneDX SBOM walkthrough. That piece was about Quarkus distribution SBOMs, dependency SBOMs, and the CycloneDX workflow around them. This one starts when security, legal, procurement, or an OSPO asks for SPDX instead. Same supply-chain conversation, different document model, and much more weight on license metadata and exchange with downstream tooling.
SPDX is not just another SBOM file format. It is an ISO standard, specifically ISO/IEC 5962:2021. It is also where many teams first hit machine-readable license expressions, package identity, and relationship data that somebody outside the application team will read later. That part matters. A scanner wants a file. A lawyer wants declared licenses. A platform team wants identifiers and relationships they can compare across releases. A customer questionnaire wants proof that your answer came from a repeatable build instead of a copied spreadsheet and a confident shrug.
The Quarkiverse quarkus-spdx project gives Quarkus a native path into that world. We build a small REST service, generate an SPDX 3.0.1 SBOM during packaging, inspect the JSON-LD structure Quarkus writes, follow a declared license relationship for one dependency, and check the result with the SPDX ntia-conformance-checker. After that, we do a short SPDX 2.3 pass because older tools and internal workflows still care about tag-value output.
What You Need
This article uses Quarkus 3.27.2, quarkus-spdx 0.0.1, and Java 21. The Quarkus version stays pinned here because the current quarkus-spdx release was built against that line, and the article should describe a setup that packages cleanly.
Java 25 installed
Quarkus CLI installed
Maven, or the Maven wrapper Quarkus generates
jqfor reading the SPDX JSON outputPython 3.10 or newer if you want to run
sbomcheckBasic Quarkus REST knowledge
About two ☕️
Three terms matter before the code:
SPDX - a standard model for software metadata, not only a bag of package names. The current specs are SPDX 3.0.1 and the older but still common SPDX 2.3.
JSON-LD - the JSON-LD serialization used by SPDX 3. The file still looks like JSON, but the @context and graph model matter because relationships and element types are first-class citizens.
License expression - the SPDX syntax for license statements such as MIT, Apache-2.0, or composite forms like Apache-2.0 OR GPL-2.0-only. SPDX makes those expressions portable. Your internal approval policy still has to decide what to do with them.
Create the Project
Create the application and follow along or check out my github repository:
quarkus create app io.mainthread:license-ledger \
--extension='rest-jackson,io.quarkiverse.spdx:quarkus-spdx-v3:0.0.1' \
--platform-bom=io.quarkus:quarkus-bom:3.27.2 \
--java=25 \
--no-code
cd license-ledgerUse these extensions:
rest-jackson- JSON REST endpointsio.quarkiverse.spdx:quarkus-spdx-v3:0.0.1- SPDX 3.0.1 SBOM generation during the Quarkus build
SPDX is not a platform-managed Quarkus core extension, so the version should be easy to verify later.
We also add one plain Maven dependency so the generated SBOM has something easy to recognize and inspect later. Add this to pom.xml inside <dependencies>:
<dependency>
<groupId>com.github.package-url</groupId>
<artifactId>packageurl-java</artifactId>
<version>1.5.0</version>
</dependency>packageurl-java is a good fit here because it is small, it is directly relevant to SBOM work, and its Maven Central entry clearly states the MIT license. That gives us a clean license relationship to inspect later.
Add a Small Service Worth Packaging
The service stays small. We only need enough application code that the packaged Quarkus distribution contains our own artifact plus one non-Quarkus dependency we can follow through the SPDX graph.
Create src/main/java/io/mainthread/licenseledger/ComponentRequest.java:
package io.mainthread.licenseledger;
public record ComponentRequest(
String supplier,
String groupId,
String artifactId,
String version,
String licenseExpression) {
}Create src/main/java/io/mainthread/licenseledger/ComponentReport.java:
package io.mainthread.licenseledger;
public record ComponentReport(
String supplier,
String coordinate,
String purl,
String licenseExpression,
String decision,
String note) {
}The request stays small. The response returns the fields we care about here: coordinate, package URL, license expression, and the decision the service made.
Now add the service in src/main/java/io/mainthread/licenseledger/ComponentCatalogService.java:
package io.mainthread.licenseledger;
import java.util.List;
import java.util.Set;
import java.util.TreeMap;
import com.github.packageurl.MalformedPackageURLException;
import com.github.packageurl.PackageURL;
import jakarta.enterprise.context.ApplicationScoped;
@ApplicationScoped
public class ComponentCatalogService {
private static final Set<String> APPROVED_LICENSES = Set.of(
"Apache-2.0",
"BSD-2-Clause",
"BSD-3-Clause",
"MIT");
private static final Set<String> REVIEW_LICENSES = Set.of(
"EPL-2.0",
"LGPL-2.1-only",
"LGPL-2.1-or-later",
"MPL-2.0");
public List<ComponentReport> sampleComponents() {
return List.of(
review(new ComponentRequest(
"Quarkus",
"io.quarkus",
"quarkus-rest-jackson",
"3.27.2",
"Apache-2.0")),
review(new ComponentRequest(
"Package URL",
"com.github.package-url",
"packageurl-java",
"1.5.0",
"MIT")),
review(new ComponentRequest(
"Example Vendor",
"org.example",
"legacy-reports",
"2.4.1",
"GPL-2.0-only")));
}
public ComponentReport review(ComponentRequest request) {
String coordinate = request.groupId() + ":" + request.artifactId() + ":" + request.version();
String licenseExpression = normalize(request.licenseExpression());
String decision = decisionFor(licenseExpression);
String note = noteFor(licenseExpression, decision);
return new ComponentReport(
normalize(request.supplier()),
coordinate,
buildPurl(request),
licenseExpression,
decision,
note);
}
private String buildPurl(ComponentRequest request) {
try {
return new PackageURL(
"maven",
request.groupId(),
request.artifactId(),
request.version(),
new TreeMap<>(java.util.Map.of("type", "jar")),
null).canonicalize();
} catch (MalformedPackageURLException e) {
throw new IllegalArgumentException("Invalid Maven coordinates for SPDX demo", e);
}
}
private String decisionFor(String licenseExpression) {
if (licenseExpression.contains(" OR ") || licenseExpression.contains(" WITH ")) {
return "manual-review";
}
if (APPROVED_LICENSES.contains(licenseExpression)) {
return "approved";
}
if (REVIEW_LICENSES.contains(licenseExpression)) {
return "manual-review";
}
if (licenseExpression.contains("GPL") || licenseExpression.contains("AGPL")) {
return "blocked";
}
return "manual-review";
}
private String noteFor(String licenseExpression, String decision) {
if ("approved".equals(decision)) {
return "Known SPDX identifier with a policy rule that can pass automatically.";
}
if ("blocked".equals(decision)) {
return "Copyleft licenses are not rejected by SPDX, but this demo policy sends them to a hard stop.";
}
if (licenseExpression.contains(" OR ") || licenseExpression.contains(" WITH ")) {
return "The SBOM can keep this SPDX expression exactly. A human still needs to decide which branch is acceptable.";
}
return "The SBOM stays useful, but this license still needs a reviewer before it reaches production.";
}
private String normalize(String value) {
return value == null ? "" : value.trim();
}
}This service does two things that matter for the article. It creates a PURL we can mention without pretending SPDX invented package identity, and it treats the SPDX license expression as data instead of reducing it to a copied string in a report. That is where the operational value starts. SPDX gives you a portable expression. Your approval policy decides how much automation you trust.
The policy is intentionally boring. MIT passes, GPL-2.0-only blocks, and composite expressions such as Apache-2.0 OR GPL-2.0-only go to manual review. That split matches how these discussions usually happen in real teams. A machine can tell you the expression. A human still decides whether the choice inside that expression is acceptable for a given release.
Expose the service over HTTP in src/main/java/io/mainthread/licenseledger/LicenseLedgerResource.java:
package io.mainthread.licenseledger;
import java.util.List;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/components")
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class LicenseLedgerResource {
private final ComponentCatalogService service;
public LicenseLedgerResource(ComponentCatalogService service) {
this.service = service;
}
@GET
@Path("/demo")
public List<ComponentReport> demo() {
return service.sampleComponents();
}
@POST
@Path("/review")
public ComponentReport review(ComponentRequest request) {
return service.review(request);
}
}We keep the REST layer thin here on purpose. The resource exists so the project is a normal Quarkus service. The interesting work stays in the service, where we can test the decision logic without needing HTTP for every branch.
Add Tests Before Packaging
Create src/test/java/io/mainthread/licenseledger/ComponentCatalogServiceTest.java:
package io.mainthread.licenseledger;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertTrue;
import org.junit.jupiter.api.Test;
class ComponentCatalogServiceTest {
private final ComponentCatalogService service = new ComponentCatalogService();
@Test
void shouldApproveKnownLicense() {
ComponentReport report = service.review(new ComponentRequest(
"Package URL",
"com.github.package-url",
"packageurl-java",
"1.5.0",
"MIT"));
assertEquals("approved", report.decision());
assertEquals("pkg:maven/com.github.package-url/packageurl-java@1.5.0?type=jar", report.purl());
}
@Test
void shouldEscalateCompositeLicenseExpression() {
ComponentReport report = service.review(new ComponentRequest(
"Example Vendor",
"org.example",
"dual-licensed-lib",
"2.0.0",
"Apache-2.0 OR GPL-2.0-only"));
assertEquals("manual-review", report.decision());
assertTrue(report.note().contains("human"));
}
@Test
void shouldBlockCopyleftLicense() {
ComponentReport report = service.review(new ComponentRequest(
"Example Vendor",
"org.example",
"legacy-reports",
"2.4.1",
"GPL-2.0-only"));
assertEquals("blocked", report.decision());
assertTrue(report.note().contains("hard stop"));
}
}Add the HTTP test in src/test/java/io/mainthread/licenseledger/LicenseLedgerResourceTest.java:
package io.mainthread.licenseledger;
import static io.restassured.RestAssured.given;
import static org.hamcrest.Matchers.equalTo;
import static org.hamcrest.Matchers.hasSize;
import org.junit.jupiter.api.Test;
import io.quarkus.test.junit.QuarkusTest;
@QuarkusTest
class LicenseLedgerResourceTest {
@Test
void shouldReturnSampleComponents() {
given()
.when().get("/components/demo")
.then()
.statusCode(200)
.body("$", hasSize(3))
.body("[0].decision", equalTo("approved"))
.body("[2].decision", equalTo("blocked"));
}
@Test
void shouldReviewSubmittedComponent() {
given()
.contentType("application/json")
.body("""
{
"supplier": "Package URL",
"groupId": "com.github.package-url",
"artifactId": "packageurl-java",
"version": "1.5.0",
"licenseExpression": "MIT"
}
""")
.when().post("/components/review")
.then()
.statusCode(200)
.body("supplier", equalTo("Package URL"))
.body("decision", equalTo("approved"))
.body("purl", equalTo("pkg:maven/com.github.package-url/packageurl-java@1.5.0?type=jar"));
}
}The unit test proves the license-policy branches. The @QuarkusTest proves the HTTP path and JSON mapping. That is enough for this example. We are not testing Quarkus itself, and we are not pretending a license policy engine needs a hundred lines of ceremonial coverage before it becomes credible.
Package the Application
Build the application:
./mvnw packageOn a Java 21 runtime, the command should finish cleanly. On my workstation with Java 25 installed globally, the build still succeeds, but Quarkus test runs emit an extra JBoss Threads warning about --add-opens java.base/java.lang=ALL-UNNAMED. That is one more reason the article stays on Java 21.
Look at the top of target/:
ls targetYou should see at least these entries:
license-ledger-1.0.0-SNAPSHOT.jar
quarkus-app
quarkus-run-spdx.jsonThat output already tells us something important. The current quarkus-spdx-v3 release writes a file named quarkus-run-spdx.json. The content is SPDX 3.0.1 JSON-LD, but the filename is still .json, not .jsonld.
Read the SPDX 3 Root
Start with the document node:
jq '.["@graph"][] | select(.type == "SpdxDocument") | {name, rootElement, profileConformance}' \
target/quarkus-run-spdx.jsonExpected output:
{
"name": "quarkus-run",
"rootElement": [
"https://spdx.org/spdxdocs/quarkus-run-.../SBOM"
],
"profileConformance": [
"software",
"core"
]
}That is already a different mental model from the SPDX 2 JSON many people remember. The document points at an SBOM element inside a graph, and the graph uses typed nodes such as software_Sbom, software_Package, and Relationship.
Now inspect the packaged root artifact:
jq '.["@graph"][] | select((.spdxId? // "") | endswith("/SPDXRef-Package-quarkus-run.jar")) | {name, comment, hashes: [.verifiedUsing[].algorithm]}' \
target/quarkus-run-spdx.jsonExpected shape:
{
"name": "quarkus-run.jar",
"comment": "Distribution path: quarkus-run.jar",
"hashes": [
"sha1",
"sha256",
"sha512",
"sha3_256",
"sha3_512",
"sha384",
"sha3_384",
"md5"
]
}This is a good place to stop and look carefully. The root is the runnable Quarkus distribution, not your Maven coordinates in isolation. That should feel familiar if you read the CycloneDX article, but the representation is different. In SPDX 3, the distribution artifact is a software_Package node with hashes and relationship edges around it.
Your own application package is still present as a separate node:
jq '.["@graph"][] | select(.type == "software_Package" and ((.spdxId? // "") | contains("io.mainthread-license-ledger-1.0.0-SNAPSHOT"))) | {name, spdxId}' \
target/quarkus-run-spdx.jsonExpected output:
{
"name": "io.mainthread:license-ledger",
"spdxId": "https://spdx.org/spdxdocs/quarkus-run-.../SPDXRef-Package-io.mainthread-license-ledger-1.0.0-SNAPSHOT"
}That split matters in practice. One node is the runnable package you ship. Another node is your application artifact as part of that build graph. People often compress those into “the app” in conversation. SPDX does not.
Follow a Declared License Relationship
The graph gets more interesting when you follow a dependency to its declared license. Use this query for packageurl-java:
jq '.["@graph"] as $g
| ($g[] | select(.type == "software_Package" and ((.spdxId? // "") | contains("com.github.package-url-packageurl-java-1.5.0"))) | .spdxId) as $pkg
| ($g[] | select(.relationshipType? == "hasDeclaredLicense" and .from == $pkg) | .to[0]) as $license
| $g[] | select(.spdxId? == $license)
| {type, expression: .simplelicensing_licenseExpression}' \
target/quarkus-run-spdx.jsonExpected output:
{
"type": "simplelicensing_LicenseExpression",
"expression": "MIT"
}This is where the topic stops being abstract for me. Once you can point to a license expression and say, “this is what our tooling can see,” the whole SBOM discussion gets more concrete. SPDX is strong here because license data is part of the model, not an awkward extra field somebody added later.
It also shows why SPDX and CycloneDX are not interchangeable just because both are SBOM formats. CycloneDX often feels closer to scanner workflows. SPDX often becomes more relevant when legal review, procurement, and standardized license expressions enter the picture. You may still want both.
Check the Result with SPDX Tooling
JSON syntax is not enough. Run an SPDX-oriented checker:
python3 -m venv .sbomcheck
.sbomcheck/bin/pip install ntia-conformance-checker
.sbomcheck/bin/sbomcheck --sbom-spec spdx3 --comply ntia target/quarkus-run-spdx.jsonExpected output:
2021 NTIA SBOM Minimum Elements Conformance Results
Conformant: False
Requirement | Status
-------------------------------------------------------
All component names provided? | True
All component versions provided? | False
All component identifiers provided? | True
All component suppliers provided? | False
SBOM author name provided? | True
SBOM creation timestamp provided? | True
Dependency relationships provided? | TrueNot a result anybody would put on a marketing slide but the generated SBOM is real. It carries relationships, identifiers, hashes, and declared license links. It still does not satisfy every NTIA minimum element out of the box for every component. The NTIA minimum-elements work, the SPDX NTIA mapping guide, and the SPDX guidance on standards and regulation are the right references here. That honest result is still better than the usual “file exists, therefore compliance is done” fiction.
If you want the 2024 CISA framing check instead:
.sbomcheck/bin/sbomcheck --sbom-spec spdx3 --comply fsct3-min target/quarkus-run-spdx.jsonOn this sample, that also fails because some versions, suppliers, and concluded licenses are missing. Again, useful result. It tells you where the generated document helps and where your process still needs more data.
When SPDX 2.3 Still Wins
SPDX 3 is the more interesting model. SPDX 2.3 is still the safer answer for older tools and teams that want tag-value output.
Swap the dependency in pom.xml:
<dependency>
<groupId>io.quarkiverse.spdx</groupId>
<artifactId>quarkus-spdx-v2</artifactId>
<version>0.0.1</version>
</dependency>Add this to src/main/resources/application.properties:
quarkus.spdx.format=allBuild again:
./mvnw packageNow target/ includes:
quarkus-run-spdx.json
quarkus-run-spdx.spdxThe JSON file is SPDX 2.3 JSON. The .spdx file is tag-value. Its first lines look like this:
SPDXVersion: SPDX-2.3
DataLicense: CC0-1.0
DocumentNamespace: https://spdx.org/spdxdocs/quarkus-run-...
DocumentName: quarkus-run
SPDXID: SPDXRef-DOCUMENTThis is the clearest practical split:
quarkus-spdx-v3gives you SPDX 3.0.1 JSON-LD with a graph model and explicit typed relationshipsquarkus-spdx-v2gives you SPDX 2.3 plus optional tag-value output, which older SPDX tooling still understands more easily
If you already know the downstream consumer wants SPDX 2.3 or tag-value, use v2 and move on. If you want the newer model and can handle JSON-LD, v3 is the more interesting path.
SPDX vs CycloneDX in Quarkus
Here is the short version, because I already spent a full article on CycloneDX:
CycloneDX is the better starting point when your main audience is scanner and CI tooling, and when you want the Quarkus-specific split between distribution SBOM and dependency SBOM that the built-in extension already gives you. If that is your problem, read Create Your First Quarkus SBOM with CycloneDX.
SPDX gets more interesting when you need standardized license expressions, SPDX-native exchange, and a document model that legal or OSPO workflows already expect. It is also where standards and regulation discussions tend to point more often. Keep the SPDX overview, the current specifications page, and the SPDX 3.0.1 spec open while you decide.
In real organizations, “pick one forever” is often the wrong question. One format may feed scanners better. Another may fit compliance and disclosure workflows better. If your release process has to satisfy both groups, generating both is less painful than arguing about which department owns reality.
Conclusion
We built a small Quarkus service, generated an SPDX SBOM with quarkus-spdx, inspected the SPDX 3.0.1 JSON-LD graph, followed a declared license expression for one dependency, and checked the result against NTIA and CISA minimum expectations. The useful mental shift is this: an SPDX file is not only an inventory. It is a graph of software elements, identities, relationships, and licenses that other teams will read long after your build finished.


