Every Spring Boot 2 to 3 migration hits the same wall: the security config. WebSecurityConfigurerAdapter was deprecated in Spring Security 5.7 and removed in 6.0, and unlike the jakarta namespace change, this one can’t be fixed with find-and-replace. You have to actually rewrite the configuration.
I’ve now done this rewrite on codebases with everything from a single permit-all filter chain to layered multi-adapter setups with custom filters. This guide covers the mechanical translation, the semantic traps, and the testing you shouldn’t skip.
The Core Change: Beans Instead of Inheritance
Spring Security 5 configuration extended a base class and overrode methods. Spring Security 6 configuration publishes beans. Here’s the same config in both styles.
Before, on Spring Security 5:
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/api/public/**").permitAll()
.antMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and()
.httpBasic();
}
@Override
public void configure(WebSecurity web) {
web.ignoring().antMatchers("/static/**");
}
}
After, on Spring Security 6:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.httpBasic(Customizer.withDefaults());
return http.build();
}
@Bean
public WebSecurityCustomizer webSecurityCustomizer() {
return web -> web.ignoring().requestMatchers("/static/**");
}
}
Three things changed at once, and it helps to see them separately:
- The adapter became a
SecurityFilterChainbean. You build theHttpSecurityobject and return the result. - The chained
.and()style became the lambda DSL. Each configurer takes a lambda. In Spring Security 7 the old chaining is gone entirely, so use lambdas now and you won’t touch this again for the Boot 4 upgrade. antMatchersbecamerequestMatchers. Same formvcMatchersandregexMatchers. One method now picks the right matching strategy.
authorizeRequests vs authorizeHttpRequests
This is not just a rename. authorizeRequests() used the old AccessDecisionManager infrastructure with SpEL-based access() rules. authorizeHttpRequests() uses the newer AuthorizationManager model.
For simple rules (permitAll, hasRole, authenticated) the translation is direct. Where you need to look closer:
// Before: SpEL string expressions
.antMatchers("/api/reports/**").access("hasRole('ADMIN') and hasIpAddress('10.0.0.0/16')")
// After: AuthorizationManager composition
.requestMatchers("/api/reports/**").access(
AuthorizationManagers.allOf(
AuthorityAuthorizationManager.hasRole("ADMIN"),
new IpAddressAuthorizationManager("10.0.0.0/16")
)
)
There’s no built-in hasIpAddress shorthand in the new model, so custom SpEL expressions become small AuthorizationManager implementations. This is the part of the migration where “it compiles” is furthest from “it works.” Inventory every access() call before you start.
Method Security
@EnableGlobalMethodSecurity is gone. The replacement is shorter:
// Before
@EnableGlobalMethodSecurity(prePostEnabled = true)
// After (prePostEnabled defaults to true)
@EnableMethodSecurity
@PreAuthorize and @PostAuthorize keep working unchanged. If you used the older @Secured or JSR-250 annotations, enable them explicitly: @EnableMethodSecurity(securedEnabled = true, jsr250Enabled = true).
AuthenticationManager Exposure
A common adapter-era pattern was overriding authenticationManagerBean() to inject the manager elsewhere, usually for a login endpoint issuing JWTs. The replacement:
@Bean
public AuthenticationManager authenticationManager(
AuthenticationConfiguration configuration) throws Exception {
return configuration.getAuthenticationManager();
}
Similarly, configure(AuthenticationManagerBuilder) overrides become plain beans: publish a UserDetailsService and a PasswordEncoder, and Spring Security wires the provider for you. If you had multiple custom AuthenticationProvider beans, publish them as beans and they’re picked up in order.
Multiple Filter Chains
Multiple WebSecurityConfigurerAdapter subclasses with @Order become multiple SecurityFilterChain beans with @Order, each scoped by securityMatcher:
@Bean
@Order(1)
public SecurityFilterChain apiChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/api/**")
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
return http.build();
}
@Bean
@Order(2)
public SecurityFilterChain webChain(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
.formLogin(Customizer.withDefaults());
return http.build();
}
The explicit securityMatcher is an improvement over the adapter days, where the first adapter silently claimed /** if you forgot to scope it. Make the scoping explicit for every chain except the last catch-all.
The Traps That Don’t Show Up at Compile Time
These are the regressions I’ve seen reach staging or worse:
- Trailing slashes. Spring Framework 6 stopped matching
/api/users/to/api/usersby default. If your rules assumed the old behavior, a protected path can become unprotected (or a public one blocked) for slash variants. Test both forms. web.ignoring()vspermitAll. Ignored paths bypass the security filter chain entirely: no security headers, no CSRF, no authentication context at all. For everything except truly static assets, preferpermitAll()inside the chain. Spring Security logs a warning about this now; listen to it.- Ordering of custom filters. Filters registered with
addFilterBefore/addFilterAfteragainst internal filter classes can land in different positions after the upgrade. Log the filter chain at startup and compare before and after. - CSRF with SPAs. Defaults around the
XorCsrfTokenRequestAttributeHandlerchanged the token contract for JavaScript clients. If your frontend reads the CSRF cookie, verify the exchange still works.
Test It Like an Attacker Would
The only reliable way to ship a security rewrite is a test suite that asserts the rules, not the implementation:
@SpringBootTest
@AutoConfigureMockMvc
class SecurityRulesTest {
@Autowired MockMvc mvc;
@Test
void adminEndpointRejectsAnonymous() throws Exception {
mvc.perform(get("/api/admin/users"))
.andExpect(status().isUnauthorized());
}
@Test
void adminEndpointRejectsWrongRole() throws Exception {
mvc.perform(get("/api/admin/users").with(user("bob").roles("USER")))
.andExpect(status().isForbidden());
}
@Test
void publicEndpointAllowsAnonymous() throws Exception {
mvc.perform(get("/api/public/health"))
.andExpect(status().isOk());
}
}
Write these tests against the OLD configuration first, confirm they pass, then do the rewrite and run them again. That turns the migration from “hope the rules survived” into a verified refactor. It’s the same approach I recommend for the broader Spring Boot 2 to 3 migration: pin behavior with tests before you touch the framework.
Do It on 2.7, Not During the Big Bang
One final piece of sequencing advice: Spring Security 5.7 (the version bundled with Boot 2.7) already supports the component-based style. Rewrite your security configuration while still on Boot 2.7, ship it, and let it run in production. Then the Boot 3 upgrade doesn’t touch your security layer at all, and if something breaks you know exactly which change caused it.
If your security configuration is deeply customized, or nobody on the team wants to own the rewrite of code that guards production, that’s a solvable problem. Security config rewrites are a regular part of my Spring Boot consulting work, and I’ve also written up general Spring Boot security best practices worth reviewing while you’re in this part of the codebase anyway.
Java Modernization Readiness Assessment
15 questions your team should answer before starting a migration. Takes 10 minutes. Could save you months.