Open Source Security

Spring Dependency Management Supply Chain

Spring Boot's dependency management is the unsung hero of the Java ecosystem, and it is also a supply chain seam worth understanding. Here is how BOMs, starters, and transitive version coercion shape what actually ships.

Nayan Dey
Senior Security Engineer
7 min read

Spring Boot is in something like 70% of the Java applications I see in enterprise audits, and the pattern is familiar: a single spring-boot-starter-parent in the POM, a dozen spring-boot-starter-* dependencies below it, and the assumption that Spring Boot "handles" dependency management so the team does not need to think about versions. That assumption is mostly correct, mostly benign, and occasionally exactly the wrong framing when a CVE lands and the team has to figure out what they actually shipped.

This post unpacks what Spring Boot dependency management is actually doing under the hood, the supply chain implications of that behavior, and how to structure your build so that the convenience does not undermine your ability to respond when something goes wrong.

What the BOM is doing

Spring Boot publishes a Bill of Materials, spring-boot-dependencies, that pins versions for something like 260 artifacts as of Spring Boot 3.2.5 (released April 2024). When you extend spring-boot-starter-parent, you inherit this BOM through the parent POM's <dependencyManagement> section, and the BOM sets the version for every dependency that Spring Boot has opinions about. You get Jackson pinned at 2.15.4, Tomcat at 10.1.20, Netty at 4.1.107.Final, Logback at 1.4.14, Micrometer at 1.12.5, and so on.

The mechanism is Maven's <dependencyManagement> scope, which sets the default version for an artifact when a child module depends on it without specifying a version, and which also overrides versions set by transitive dependencies. The second behavior is the important one. If you depend on Library X which depends on Jackson 2.13.0, and the Spring Boot BOM says Jackson 2.15.4, your build ends up with Jackson 2.15.4, not 2.13.0. This is usually what you want. It is also the mechanism that silently upgrades transitive CVE exposure in ways that are easy to lose track of.

The upgrade arbitrage

Here is the subtle part. The Spring Boot team is much more aggressive about CVE-driven upgrades than most individual library maintainers. When CVE-2022-1471 hit snakeyaml in October 2022, Spring Boot 2.7.6 shipped within a month with snakeyaml 1.33, overriding the older versions pulled by various transitives. When CVE-2023-20861 (Spring Expression Language DoS) landed in March 2023, Spring Boot 3.0.5 shipped days later. When CVE-2024-22257 (Spring Security broken access control) was disclosed in March 2024, Spring Boot 3.2.4 followed within two weeks.

The practical effect is that teams on current Spring Boot get a lot of transitive CVE remediation for free. The teams that are on Spring Boot 2.7.x past its commercial support end (November 2023 for the OSS branch, later for paid commercial support) are effectively opting out of this arbitrage, and their exposure window grows monthly. Spring Boot 3.0 ended OSS support in November 2023, 3.1 in May 2024, and 3.2 runs to November 2024. The upgrade cadence is tight by design.

Version coercion and the transitive you did not expect

The BOM-driven version coercion is powerful and it occasionally produces surprises. Imagine you depend on a third-party library that requires Jackson 2.16.x for an API change, and your Spring Boot 3.2 pins Jackson 2.15.4. The build resolves to 2.15.4, your third-party library compiles against 2.16, and at runtime you get a NoSuchMethodError because the bytecode expected the 2.16 API. The fix is usually a property override in your POM: <jackson-bom.version>2.16.2</jackson-bom.version> pushes the BOM-driven version up.

The security-relevant version of this same story is the inverse. Spring Boot 3.1.3 shipped with a version of grpc-netty that had CVE-2023-4586, because the fix had not yet made it into the Spring Boot release branch. If your application had its own reason to pin a newer grpc-netty, you were protected; if you did not, you inherited the vulnerable version even though you probably had the option to upgrade. The lesson is that "Spring Boot pinned it, so it must be right" is a reasonable default and a bad absolute rule. Periodically audit the BOM-pinned versions against CVE feeds.

Starters and the zero-config trap

Spring Boot starters are aggregator artifacts that pull in curated sets of dependencies. spring-boot-starter-web pulls in Spring MVC, Tomcat, Jackson, Validation, and a few other things. spring-boot-starter-data-jpa pulls in Hibernate, Spring Data JPA, a JPA API, a JDBC starter, and transitives for connection pooling.

Starters are great for velocity and they are opaque for supply chain accounting. The starter POM itself has maybe five dependencies, each of which has ten more, each of which has five more. A spring-boot-starter-web resolves to roughly forty artifacts in the final classpath on Spring Boot 3.2. If you are doing an SBOM audit, the starter entry is almost never the interesting thing; the interesting thing is the transitive graph that the starter expanded into.

This is why I argue for running mvn dependency:tree -Dverbose=true on every release and diffing the output against the previous release. The BOM version changes are visible, but so are the cases where a minor Spring Boot upgrade changed the transitive graph in a way that pulled in a new artifact nobody had vetted.

Dependency management plugin vs parent POM inheritance

There are two ways to apply the Spring Boot BOM: inherit from spring-boot-starter-parent, or use the Spring Dependency Management Gradle/Maven plugin with an explicit import of spring-boot-dependencies. The two are not equivalent. Parent POM inheritance gives you everything in the parent, including plugin configurations and build settings. The plugin-based approach gives you only the dependency management.

From a supply chain review perspective, the plugin-based approach is cleaner. Your project's effective BOM is explicit in your build script, not implicit in an inherited parent. When you review the build at audit time, you see exactly what BOM version is applied and where. With parent POM inheritance, the BOM version is embedded in the parent's POM and you have to dig one level deeper to check it.

For Gradle users, io.spring.dependency-management 1.1.5 is the current stable as of April 2024. For Maven users, the equivalent is to not extend spring-boot-starter-parent and instead add spring-boot-dependencies to your <dependencyManagement> with type=pom and scope=import. Either pattern produces cleaner audit output than parent inheritance.

The lock file conversation

Spring Boot does not produce a lock file by default. Maven does not have lock files as a first-class concept; Gradle has dependencyLocking. Whether to use lock files with Spring Boot is a debate with legitimate arguments on both sides. The pro-lockfile case is reproducibility: the lockfile captures the exact resolved versions and commits them to the repo, so a CI build three months from now produces the same classpath as today. The anti-lockfile case is that it competes with Spring Boot's version management and produces conflicts when you upgrade the BOM.

The pattern that works is to use lock files and regenerate them as part of the Spring Boot upgrade commit. The lockfile becomes the release-branch record of what was shipped, and the regeneration is a deliberate act tied to the upgrade PR. This is the pattern the Spring Boot team themselves use internally.

VEX statements and unreachable CVEs

One workflow that has matured in 2024 is VEX (Vulnerability Exploitability eXchange) statements for the Spring ecosystem. Many CVEs in Spring Boot transitives do not actually affect Spring Boot users because the vulnerable code path is never reached through the Spring-integrated API. The CycloneDX and SPDX communities have both added VEX support, and the Spring Boot team has begun publishing VEX documents for certain transitive CVEs. If your SBOM tooling consumes VEX, you can suppress false positives in your scan output without pretending the CVE does not exist.

How Safeguard Helps

Safeguard tracks the full resolved dependency tree for every Spring Boot module in your tenant, not just the direct dependencies in your POM, and we correlate those resolutions against the Spring Boot BOM version you are using. When a transitive CVE lands, we show you which of your Spring Boot projects are exposed and whether upgrading to a newer Spring Boot release would automatically fix it through BOM version coercion. We also ingest Spring team-published VEX documents so your scan results reflect actual exploitability, not just version matching. Teams using Safeguard with Spring Boot cut their transitive-CVE triage time by roughly 70% because they stop chasing issues the Spring BOM has already addressed.

Never miss an update

Weekly insights on software supply chain security, delivered to your inbox.