Skip to content
Ashish.
All posts
Diagram illustrating the migration path from Spring Boot 2 to Spring Boot 3 and Spring Security 5 to 6.

Spring Security 6 and Spring Boot 3: Migration Guide

A practical walkthrough for migrating applications to Spring Security 6 and Spring Boot 3, covering Jakarta EE transitions and essential security steps.

By Ashish SrivastavaPart 11 of Spring Security Deep Dive Series

This is Part 11 of the Spring Security Deep Dive Series.

The migration from Spring Boot 2.x to 3.x is often mistaken for a routine dependency update. It is not. It is a structural reorganization of the application's classpath driven by the finalization of the Jakarta EE 9+ specification. Spring Boot 3 requires Java 17 as a baseline and enforces the use of the jakarta.* namespace, replacing the legacy javax.* packages that defined Java EE for over a decade. This shift breaks the binary compatibility of almost every component in the stack, including Spring Security itself. The goal of this guide is to navigate the specific mechanism of this namespace collision and the architectural shift in how security policies are defined.

The Jakarta EE Namespace Collision

The root cause of the migration friction is the trademark dispute resolution between Oracle and the Eclipse Foundation. When Jakarta EE 9 was released, the package prefix for all enterprise APIs changed from javax to jakarta. Spring Boot 3 adopted this standard immediately.

In your application code, any import statement referencing javax.servlet, javax.annotation, or javax.validation will now fail to compile. The compiler cannot find these classes because they have been physically moved to jakarta.servlet, jakarta.annotation, and jakarta.validation.

Consider a standard controller or service that previously looked like this:

import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
import org.springframework.stereotype.Service;
 
@Service
public class UserService {
    public void process(HttpServletRequest request) {
        // Logic here
    }
}

Upon upgrading to Spring Boot 3, this code must be refactored to:

import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import org.springframework.stereotype.Service;
 
@Service
public class UserService {
    public void process(HttpServletRequest request) {
        // Logic here
    }
}

This change is not merely cosmetic; it affects the classloader. If you have custom filters or interceptors that extend javax.servlet.Filter or implement javax.servlet.FilterChain, they will throw ClassCastException or fail to load entirely because the base classes no longer exist on the classpath.

You must also audit your third-party dependencies. Libraries like Hibernate Validator, Jackson, and Lombok have released Spring Boot 3 compatible versions that explicitly depend on Jakarta EE. If you rely on older versions of these libraries, they will remain on javax and conflict with the new runtime.

<!-- pom.xml dependency update for Jakarta compatibility -->
<dependency>
    <groupId>org.hibernate.validator</groupId>
    <artifactId>hibernate-validator</artifactId>
    <version>8.0.1.Final</version> <!-- Requires Jakarta -->
</dependency>

The Security Filter Chain Mechanism

The most significant behavioral change in Spring Security 6 is the deprecation of the WebSecurityConfigurerAdapter class. In Spring Security 5.x, developers extended this class to define security rules. This approach relied on inheritance and a specific initialization order that made the security context difficult to reason about in complex applications.

Spring Security 6 introduces a functional approach where the security configuration is defined by a SecurityFilterChain bean. This bean encapsulates the entire security chain, allowing for multiple filter chains to coexist without the ambiguity of inheritance hierarchies. The mechanism here is explicit: the framework no longer scans for a subclass of WebSecurityConfigurerAdapter; it scans for beans of type SecurityFilterChain.

To migrate, you must replace the class extension with a @Bean method. The configuration logic remains similar, but the entry point changes.

Legacy Spring Security 5 configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
    }
}

New Spring Security 6 configuration:

@Configuration
@EnableWebSecurity
public class SecurityConfig {
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http.authorizeHttpRequests(auth -> auth.anyRequest().authenticated());
        return http.build();
    }
}

Notice that http is now passed as an argument to the bean method, rather than being injected via the class instance. This allows you to define multiple SecurityFilterChain beans if your application requires different security rules for different URL patterns, a pattern that was possible but cumbersome in the adapter model.

If you have custom filters, they must now be added using the addFilterBefore or addFilterAfter methods within the configure lambda, rather than overriding the configure method of the adapter. The order of execution is now determined by the Order value of the filter or the default ordering defined by Spring Security, which can be influenced by DSL insertion order but is not strictly equivalent to it.

OAuth2 and Resource Server Evolution

The migration path for OAuth2 and OIDC configurations has also shifted to align with the new filter chain model. In Spring Security 5, OAuth2 support was often configured via configure(HttpSecurity http) blocks that mixed resource server and client logic. Spring Security 6 separates these concerns more cleanly and removes deprecated classes that cluttered the API.

Specifically, the OAuth2ResourceServerConfigurer and related classes have been refactored. If you were using the JwtDecoder bean, the configuration now relies heavily on the SecurityFilterChain bean to wire the decoder into the request processing pipeline.

A typical OAuth2 Resource Server configuration in Spring Boot 3 looks like this:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .oauth2ResourceServer(oauth2 -> oauth2
            .jwt(jwt -> jwt
                .decoder(jwtDecoder()))
        )
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public").permitAll()
            .anyRequest().authenticated()
        );
    return http.build();
}
 
@Bean
public JwtDecoder jwtDecoder() {
    return NimbusReactiveJwtDecoder.withJwkSetUri("https://your-auth-server.com/.well-known/jwks.json")
            .build();
}

The NimbusReactiveJwtDecoder class is used here for reactive contexts, though standard NimbusJwtDecoder remains valid for servlet-based applications. The integration with the HttpSecurity object is now strictly typed within the oauth2ResourceServer DSL. This ensures that the JWT decoding happens at the correct point in the filter chain, before the authentication manager processes the request.

If you are migrating from a legacy OAuth2AuthorizedClientManager setup, ensure you are using the AuthorizationServer configuration correctly. While the framework supports dual-mode configurations (acting as both client and resource server in a single app), best practice recommends separating concerns into distinct filter chains for clarity, rather than mixing configurations in a single HttpSecurity instance. You should explicitly define which endpoints are handled by the client and which by the resource server to maintain architectural clarity.

Dependency Management and Version Alignment

Finally, the migration requires a strict adherence to version alignment. You cannot mix Spring Boot 3 dependencies with Spring Security 5.x artifacts. The spring-boot-starter-security artifact in Spring Boot 3 pulls in Spring Security 6.0.x automatically.

When you update your pom.xml or build.gradle, you must ensure that the parent version is set to 3.x.

<parent>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-parent</artifactId>
    <version>3.2.0</version>
</parent>

Failure to update the parent version will result in the build system resolving older, incompatible artifacts. Additionally, check for any custom security filters or utilities that might be using internal Spring Security APIs that were deprecated in 5.x and removed in 6.x. The removal of these internal APIs is a common source of runtime errors post-migration.

In summary, the migration to Spring Boot 3 and Spring Security 6 is a two-part process: a namespace cleanup from javax to jakarta and a structural refactoring of security configuration from inheritance-based adapters to functional bean definitions. By addressing the package conflicts first and then rewriting the SecurityFilterChain beans, you ensure that your application leverages the improved modularity and type safety of the new framework versions.

Conclusion

Successfully migrating to Spring Boot 3 and Spring Security 6 demands more than a simple version bump; it requires a deliberate restructuring of your application's foundational layers. By systematically addressing the javax to jakarta namespace shift and adopting the functional SecurityFilterChain model, you resolve the core incompatibilities that prevent problematic upgrades. This approach not only aligns your application with modern Java standards but also sets a solid foundation for future security enhancements and maintainability.

Common Pitfalls

During the migration process, developers frequently encounter specific pitfalls that can stall the upgrade. Awareness of these issues can save significant debugging time.

  • Missing Jakarta Imports: The most immediate error is the inability to resolve javax.* imports. Unlike a simple compilation error, this often manifests as ClassNotFoundException at runtime if the code compiles due to some transitive dependency masking the issue. Ensure every single import in your project is updated to jakarta.*.
  • Deprecated WebSecurityConfigurerAdapter Usage: Attempting to extend WebSecurityConfigurerAdapter in Spring Security 6 results in a compilation error. Do not attempt to use the old inheritance model; refactor immediately to the functional SecurityFilterChain bean approach.
  • Incorrect JWT Decoder Configuration: Using the static NimbusJwtDecoder.withJwkSetUri(...).build() pattern directly within the configuration without defining it as a separate bean can lead to issues in Spring Security 6.1+. It is preferred to define the JwtDecoder as a distinct @Bean to allow for proper dependency injection and lifecycle management.

Practical Takeaways

To navigate this migration effectively, adopt the following mental models and rules of thumb:

  1. Namespace First, Security Second: Tackle the javax to jakarta refactoring before touching any security logic. Getting the classpath and dependencies aligned first prevents masking errors when you later refactor the security chain.
  2. Functional Over Inheritance: Treat the SecurityFilterChain bean as a composition of rules rather than an extension of a base class. This makes your configuration more testable and easier to manage in complex applications with multiple security domains.
  3. Validate Dependency Versions: Always verify that your third-party libraries (Hibernate, Jackson, Lombok) have explicit Spring Boot 3/Jakarta EE compatibility. Relying on default transitive versions is a common cause of build failures.

FAQ

Q: Can I keep using WebSecurityConfigurerAdapter if I stay on Java 17? A: No. WebSecurityConfigurerAdapter was removed in Spring Security 6, regardless of the Java version. You must migrate to the SecurityFilterChain bean pattern.

Q: Do I need to rewrite my OAuth2 client configuration? A: Yes, but the core logic remains similar. You need to ensure the OAuth2 Client configuration uses the new SecurityFilterChain bean structure and that the ClientRegistrationRepository is properly wired.

Q: What happens if I forget to update a single javax.servlet import? A: The build will likely fail during compilation. If the missing class is part of a transitive dependency that isn't explicitly imported, you may see runtime NoClassDefFoundError exceptions when the application attempts to load the filter or servlet.

Related posts