Before I could meaningfully remediate the 188 vulnerabilities Snyk found in MFlix, I had to confront something uncomfortable.
The project structure itself was the problem.
Not the code — the code was fine for what it was. But the way it was organised, configured, and built reflected 2018 Spring Boot conventions that created friction for every subsequent change. Trying to apply modern security fixes to an unrenovated codebase is like trying to rewire a house without updating the fuse box. You can do it, but every step is harder than it needs to be.
This article is about the modernisation work I did before touching a single CVE — what the 2019 structure looked like, what I changed, why I changed it, and what I deliberately kept.
What a 2019 Spring Boot Project Looks Like
When MFlix was built, Spring Boot 2.0.x was the current major version. Java 8 was the standard enterprise runtime. The project structure followed conventions of that era:
mflix/
├── pom.xml
├── src/
│ ├── main/
│ │ ├── java/
│ │ │ └── mflix/
│ │ │ ├── api/
│ │ │ │ ├── MoviesController.java
│ │ │ │ └── UsersController.java
│ │ │ ├── config/
│ │ │ │ └── MongoDBConfiguration.java
│ │ │ └── daos/
│ │ │ ├── MovieDao.java
│ │ │ └── UserDao.java
│ │ └── resources/
│ │ └── application.properties
│ └── test/
│ └── java/
│ └── mflix/
Functional. Reasonable for its time. But several things stood out immediately when I looked at it with fresh eyes in 2025:
Java 8 compiler target. The pom.xml declared <source>1.8</source> and <target>1.8</target>. Java 8 reached end-of-life for free Oracle support in January 2019 — the same month this project was likely being committed. Six years of security patches, language improvements, and performance gains left on the table.
Mixed Spring Boot versions. The pom.xml declared spring-boot-starter-web@2.0.3 and spring-boot-starter-security@2.0.4 separately, with explicit version pinning on individual Spring Framework components (spring-context, spring-core, spring-web all at 5.0.7). Modern Spring Boot projects use a parent BOM (Bill of Materials) that manages version alignment across the entire Spring ecosystem. Manually pinning individual Spring component versions is how you end up with the kind of version drift that generates 188 CVEs.
No dependency management section. Without a <dependencyManagement> block or a parent BOM, transitive dependency versions are determined entirely by whatever the top-level dependencies pull in — with no explicit control or visibility.
application.properties with a hardcoded MongoDB URI. The connection string for the MongoDB Atlas cluster was in the properties file rather than being externalised to environment variables. That's not a Snyk finding, but it's a security hygiene issue that should be addressed before anything else.
The Modernisation Goals
I set three goals before writing a line of changed code:
Goal 1: Move to a Spring Boot parent BOM. This single change would bring version alignment across the entire Spring ecosystem under centralised control. Every Spring component version becomes managed by the BOM rather than individually pinned.
Goal 2: Upgrade the Java target to 17. Java 17 is the current LTS release and the minimum target for Spring Boot 3.x. Moving from Java 8 to Java 17 closes nine years of language evolution and gives access to Spring Boot 3.x's security improvements.
Goal 3: Externalise secrets. The MongoDB connection URI, JWT signing key, and any other credentials needed to move out of application.properties and into environment variables before any other change.
Goal 3 was intentionally first. Before running any security tooling or making any dependency changes, the sensitive configuration needed to be out of the code.
Step 1: Externalising Secrets
The application.properties contained:
spring.data.mongodb.uri=mongodb+srv://admin:password@cluster.mongodb.net/mflix
jwt.secret=mflix-jwt-secret-key
Both values needed to go. The replacement:
# application.properties — safe to commit
spring.data.mongodb.uri=${MONGODB_URI}
jwt.secret=${JWT_SECRET}
# .env — never committed, in .gitignore
MONGODB_URI=mongodb+srv://admin:password@cluster.mongodb.net/mflix
JWT_SECRET=mflix-jwt-secret-key
And .gitignore updated:
.env
*.env
application-local.properties
Simple. Ten minutes. Should have been done in 2019. The secrets detector I wrote would have caught both of these had it been running as a pre-commit hook — which is a satisfying bit of cross-project validation.
Step 2: Introducing the Spring Boot Parent BOM
The single most impactful structural change in the modernisation was adding the Spring Boot parent BOM.
Before:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>mongodb.university</groupId>
<artifactId>mflix</artifactId>
<version>1.0-SNAPSHOT</version>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>2.0.3.RELEASE</version>
</dependency>
<dependency>
<groupId>org.springframework</groupId>
<artifactId>spring-context</artifactId>
<version>5.0.7.RELEASE</version>
</dependency>
<!-- etc — every version pinned manually -->
</dependencies>
</project>
After:
<project>
<modelVersion>4.0.0</modelVersion>
<groupId>mongodb.university</groupId>
<artifactId>mflix</artifactId>
<version>1.0-SNAPSHOT</version>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.2.5</version>
<relativePath/>
</parent>
<properties>
<java.version>17</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<!-- No version — managed by parent BOM -->
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
<!-- No version — managed by parent BOM -->
</dependency>
<!-- Individual spring-context, spring-core, spring-web removed -->
<!-- BOM pulls in correct aligned versions automatically -->
</dependencies>
</project>
What this change does:
- The parent BOM declares tested, compatible versions for the entire Spring ecosystem
- Individual Spring Framework components (
spring-context,spring-core,spring-web) no longer need to be declared separately — they're pulled in as transitive dependencies of the starters at the correct version -
java.versionproperty drives compiler configuration through the parent's plugin management - All Spring component versions move in lockstep, eliminating the version drift that contributed to the CVE accumulation
The version jump from 2.0.3 to 3.2.5 is a major version upgrade. Spring Boot 3.x dropped support for Java 8, requires Jakarta EE 10 namespace (
jakarta.*instead ofjavax.*), and brought a range of breaking API changes. Those breaking changes are what make this step the most work-intensive part of the modernisation.
Step 3: The Jakarta Namespace Migration
Spring Boot 3.x moved from the javax.* namespace (Java EE) to jakarta.* (Jakarta EE). Every import in the codebase that referenced javax.servlet, javax.persistence, or similar needed updating.
In MFlix, the affected imports were primarily in the security configuration and controller layer:
Before:
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.validation.Valid;
After:
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
This is mechanical work rather than architectural work — find and replace across the codebase. Modern IDEs handle it automatically with a refactoring tool. The risk is missing an occurrence, which produces a compile error rather than a runtime bug, so it's catchable.
Step 4: Spring Security Configuration Modernisation
The biggest code change in the modernisation was the Spring Security configuration. Spring Boot 3.x deprecated and then removed the WebSecurityConfigurerAdapter pattern that was standard in Spring Boot 2.x.
The 2019 pattern (deprecated, removed in Spring Boot 3.x):
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/v1/movies/**").permitAll()
.antMatchers("/api/v1/users/login").permitAll()
.anyRequest().authenticated()
.and()
.sessionManagement()
.sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(userDetailsService)
.passwordEncoder(passwordEncoder());
}
}
The modern pattern (Spring Boot 3.x):
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/movies/**").permitAll()
.requestMatchers("/api/v1/users/login").permitAll()
.anyRequest().authenticated()
)
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
);
return http.build();
}
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration config) throws Exception {
return config.getAuthenticationManager();
}
}
The functional change is minimal — the same security rules apply. The structural change is significant: instead of extending an abstract class and overriding methods, the configuration is composed through beans with a fluent lambda-based API. The new pattern is cleaner, more testable, and aligns with Spring's component model more naturally.
Two specific API changes worth noting:
antMatchers() → requestMatchers() — The method rename is straightforward but easy to miss because both compile without error in certain configurations; only the runtime behaviour differs.
authorizeRequests() → authorizeHttpRequests() — This change has security implications beyond naming. authorizeHttpRequests() uses the newer AuthorizationManager API which short-circuits earlier in the request processing chain and is more consistent in its behaviour across different dispatcher types.
Step 5: JWT Library Migration
The jjwt library had its own breaking change to address. io.jsonwebtoken:jjwt@0.9.1 — which Snyk gave a priority score of 889 and found 58 fixable issues in — underwent a major API restructuring between 0.9.x and 0.12.x.
Before (jjwt 0.9.1 API):
String token = Jwts.builder()
.setSubject(userId)
.setIssuedAt(new Date())
.setExpiration(expiration)
.signWith(SignatureAlgorithm.HS256, secret)
.compact();
Claims claims = Jwts.parser()
.setSigningKey(secret)
.parseClaimsJws(token)
.getBody();
After (jjwt 0.12.0 API):
String token = Jwts.builder()
.subject(userId)
.issuedAt(new Date())
.expiration(expiration)
.signWith(secretKey, Jwts.SIG.HS256)
.compact();
Claims claims = Jwts.parser()
.verifyWith(secretKey)
.build()
.parseSignedClaims(token)
.getPayload();
The key change — beyond the fluent API restructuring — is how the signing key is handled. jjwt 0.9.1 accepted a raw String as a signing key, which is a known security weakness. A short or low-entropy string could be brute-forced if an attacker obtained a token. jjwt 0.12.0 requires a proper SecretKey object, which enforces minimum key length requirements at the API level.
// 0.9.1 — accepts any string, no validation
.signWith(SignatureAlgorithm.HS256, "weak")
// 0.12.0 — requires proper SecretKey, enforces minimum length
SecretKey key = Keys.hmacShaKeyFor(
Decoders.BASE64.decode(base64EncodedSecret)
);
.signWith(key, Jwts.SIG.HS256)
This is an example of a library upgrade that isn't just a security patch — it's a security design improvement. The new API makes it harder to write insecure code, not just patching a specific vulnerability.
What I Deliberately Kept
Not everything needed to change.
The DAO layer. The MongoDB operations in MovieDao and UserDao use the Java driver directly with proper parameterised queries. The code is correct, readable, and doesn't need to be rewritten just because the framework version changed. Unnecessary refactoring introduces risk without benefit.
The API structure. The REST endpoint design in MoviesController and UsersController is sound. RESTful conventions, appropriate HTTP status codes, clear URL structure. These don't need to change to fix security issues.
The test suite. The JUnit 5 tests — updated to junit-jupiter-api@5.10.x from 5.1.0 — largely passed after the namespace migration. Keeping the tests as close to their original form as possible gave me confidence that the modernisation hadn't changed the application's behaviour.
The guiding principle: change what needs to change for security and maintainability. Don't rewrite what doesn't need to be rewritten.
The Modernisation Diff — What Actually Changed
Summarising the changes as a before/after:
| Area | Before | After |
|---|---|---|
| Java version | 1.8 (Java 8) | 17 |
| Spring Boot | 2.0.3–2.0.4 (manually pinned) | 3.2.5 (BOM managed) |
| Spring Framework | 5.0.7 (manually pinned) | 6.1.x (BOM managed) |
| jjwt | 0.9.1 | 0.12.0 |
| Security config | WebSecurityConfigurerAdapter |
SecurityFilterChain bean |
| Namespace | javax.* |
jakarta.* |
| Secrets | Hardcoded in properties file | Environment variables |
| Dependency versions | 8 manually pinned | BOM managed |
What the Modernisation Did to the Snyk Results
Running Snyk after the modernisation — before doing any targeted vulnerability remediation — already moved the numbers significantly.
The BOM upgrade to Spring Boot 3.2.5 automatically resolved a large portion of the Spring-related CVEs because the BOM pulled in patched versions of Spring Framework, Tomcat, and Jackson. The jjwt upgrade to 0.12.0 cleared all 58 of its fixable issues in a single version change.
I'll show the full before/after numbers in article 6. For now, the preview: the modernisation alone — without any targeted CVE remediation — reduced the total finding count substantially. This illustrates an important point about legacy Java dependency management: often the most effective security intervention isn't patching individual CVEs, it's getting the project onto a supported version of its primary framework and letting the framework's dependency management do the heavy lifting.
The modernised repository is at github.com/pgmpofu/mflix on the modernised branch.
Next up: the full unfiltered Snyk results — a deeper look at the most significant findings, what each vulnerability actually enables an attacker to do, and why the RCE in spring-beans deserved the attention it got.
Top comments (0)