← WritingTechnology · 12 min read

WebSecurityConfigurerAdapter no longer exists in Spring Security 7. Here is how a classic Spring Boot 2 security setup (in-memory users, database users and JWT login) looks on Spring Boot 4.1, side by side and tested.

Spring Security Without WebSecurityConfigurerAdapter: Updating Spring Boot 2 Security Code for Spring Boot 4

A lot of Spring Security code you will find, in tutorials, courses and existing projects, begins like this:

public class SecurityConfig extends WebSecurityConfigurerAdapter {

On Spring Boot 4.1, that line does not compile. WebSecurityConfigurerAdapter is not just deprecated; it has been removed from Spring Security, along with much of the configuration style that went with it.

This post takes a typical Spring Boot 2.6 security setup and updates it, one step at a time, for Spring Boot 4.1. The example is the car database REST API from Juha Hinkula's Full Stack Development with Spring Boot and React: first an in-memory user, then users stored in MariaDB, then JWT login. I compiled every snippet and tested it against a running app.

The versions involved#

Spring Boot 2 codeThis post
Spring Boot2.64.1.1
Spring Security5.67.1.1
Java1127
Persistence importsjavax.persistencejakarta.persistence
jjwt (JWT library)0.11.20.13.0
DatabaseMariaDBMariaDB 10.6

The security changes arrived in three steps:

  • Spring Security 5.7 (2022) deprecated WebSecurityConfigurerAdapter.
  • Spring Security 6.0 (Spring Boot 3) removed it, along with antMatchers(). Java EE packages moved from javax.* to jakarta.*.
  • Spring Security 7.0 (Spring Boot 4) removed authorizeRequests(), the .and() chaining style and every configuration method that does not take a lambda.

The last step is the one that catches people out. Many migration guides online still show .and() and authorizeRequests() in their "new" code. That code does not compile on Spring Boot 4. I checked the compiled HttpSecurity class in 7.1.1: csrf, cors, sessionManagement, authorizeHttpRequests and exceptionHandling each exist only in their lambda form.

The one idea behind the change#

The old approach was inheritance. You extended a base class and overrode its methods: configure(HttpSecurity) for URL rules, configure(AuthenticationManagerBuilder) for users, authenticationManagerBean() to expose the authentication manager.

The new approach is beans. You declare the pieces you need as @Bean methods and Spring Boot puts them together:

Old (extend the adapter)New (declare a bean)
configure(HttpSecurity http)@Bean SecurityFilterChain filterChain(HttpSecurity http) that ends with return http.build();
configure(AuthenticationManagerBuilder auth)a UserDetailsService bean and a PasswordEncoder bean
authenticationManagerBean() / authenticationManager()AuthenticationConfiguration.getAuthenticationManager()
configure(WebSecurity web)@Bean WebSecurityCustomizer

Inside the filter chain, the configuration language changed too:

Old DSLNew DSL
.authorizeRequests().authorizeHttpRequests(auth -> auth ...)
.antMatchers("/login").requestMatchers("/login")
.csrf().disable().csrf(csrf -> csrf.disable())
.cors().cors(Customizer.withDefaults())
.sessionManagement().sessionCreationPolicy(...).sessionManagement(s -> s.sessionCreationPolicy(...))
.and()gone. Each section is its own lambda, so nothing needs to be closed.

Once you see the pattern, every piece translates mechanically. The example builds security up in three stages, so I will follow the same order.

Stage 1: the default login and an in-memory user#

Before (Spring Boot 2.6)#

Adding spring-boot-starter-security locks every endpoint behind a generated password. The Spring Boot 2 code then creates a configuration class that extends the adapter with an empty configure(HttpSecurity) override, and adds a test user by overriding userDetailsService():

// Before: Spring Boot 2.6
@Bean
@Override
public UserDetailsService userDetailsService() {
    UserDetails user = User.withDefaultPasswordEncoder()
        .username("user")
        .password("password")
        .roles("USER")
        .build();
    return new InMemoryUserDetailsManager(user);
}

What it looks like now#

There is no base class, so there is nothing to override. The class becomes a plain @Configuration, and the empty configure method becomes a SecurityFilterChain bean. To keep the same default behaviour (every request needs a login, with a browser form or HTTP Basic), spell that out:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .authorizeHttpRequests(auth -> auth
                .anyRequest().authenticated())
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults());
        return http.build();
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public InMemoryUserDetailsManager userDetailsService(PasswordEncoder passwordEncoder) {
        UserDetails user = User.withUsername("user")
            .password(passwordEncoder.encode("password"))
            .roles("USER")
            .build();
        return new InMemoryUserDetailsManager(user);
    }
}

Three things changed:

  1. No @Override and no extends. Each method is just a bean.
  2. withDefaultPasswordEncoder() is deprecated. It still exists in 7.1.1, but its deprecation message reads "considered unsafe for production and is only intended for sample applications". Inject a real PasswordEncoder instead.
  3. @EnableWebSecurity is optional in Spring Boot, because Boot turns web security on automatically once the starter is present. I keep it because it makes the class's purpose obvious, and it does no harm.

Stage 2: users from the database#

Before (Spring Boot 2.6)#

The Spring Boot 2 version stores users in a User entity, looks them up with a UserDetailsService implementation, and connects them to Spring Security through a configureGlobal method:

// Before: Spring Boot 2.6
@Autowired
private UserDetailsServiceImpl userDetailsService;

@Autowired
public void configureGlobal(AuthenticationManagerBuilder auth) throws Exception {
    auth.userDetailsService(userDetailsService)
        .passwordEncoder(new BCryptPasswordEncoder());
}

What it looks like now#

Delete configureGlobal entirely. In Spring Boot, if there is exactly one UserDetailsService bean and one PasswordEncoder bean, Spring Security connects them automatically. Your @Service class already is the UserDetailsService bean, so all SecurityConfig needs is the encoder (and you remove the in-memory user from Stage 1):

@Bean
public PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder();
}

The service itself still compiles. Two small modern touches make it shorter: constructor injection instead of @Autowired on a field, and Optional.map(...).orElseThrow(...) instead of isPresent() and get():

@Service
public class UserDetailsServiceImpl implements UserDetailsService {

    private final UserRepository repository;

    public UserDetailsServiceImpl(UserRepository repository) {
        this.repository = repository;
    }

    @Override
    public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
        return repository.findByUsername(username)
            .map(user -> User.withUsername(user.getUsername())
                .password(user.getPassword())
                .roles(user.getRole())
                .build())
            .orElseThrow(() -> new UsernameNotFoundException("User not found."));
    }
}

The User in this class is Spring Security's org.springframework.security.core.userdetails.User, not your own entity. I do not import the entity here, because the repository already returns it. If you need both in one file, use the full package name for one of them.

The entity needs one change: javax.persistence.* becomes jakarta.persistence.*.

Seeding users#

The original seeds its test users with BCrypt hashes generated ahead of time. That still works, but now that PasswordEncoder is a bean, you can inject it into the application class and hash the passwords in code:

@Autowired
private PasswordEncoder passwordEncoder;

// inside run(...)
urepository.save(new User("user", passwordEncoder.encode("user"), "USER"));
urepository.save(new User("admin", passwordEncoder.encode("admin"), "ADMIN"));

@RepositoryRestResource(exported = false) on UserRepository still works unchanged, and it stays important. Without it, Spring Data REST publishes your users and their password hashes at /api/users.

Stage 3: JWT authentication#

This stage relies on the adapter most, so it is where the most changes add up.

Before (Spring Boot 2.6)#

The final configuration has an overridden configure, a configureGlobal, and an AuthenticationManager bean that calls a method inherited from the adapter:

// Before: Spring Boot 2.6
@Bean
public AuthenticationManager getAuthenticationManager() throws Exception {
    return authenticationManager();   // inherited from the adapter
}

@Override
protected void configure(HttpSecurity http) throws Exception {
    http.csrf().disable().cors().and()
        .sessionManagement()
        .sessionCreationPolicy(SessionCreationPolicy.STATELESS).and()
        .authorizeRequests()
        .antMatchers(HttpMethod.POST, "/login").permitAll()
        .anyRequest().authenticated().and()
        .exceptionHandling()
        .authenticationEntryPoint(exceptionHandler).and()
        .addFilterBefore(authenticationFilter,
            UsernamePasswordAuthenticationFilter.class);
}

What it looks like now#

Here is the full, working SecurityConfig:

@Configuration
@EnableWebSecurity
public class SecurityConfig {

    private final AuthenticationFilter authenticationFilter;
    private final AuthEntryPoint exceptionHandler;

    public SecurityConfig(AuthenticationFilter authenticationFilter, AuthEntryPoint exceptionHandler) {
        this.authenticationFilter = authenticationFilter;
        this.exceptionHandler = exceptionHandler;
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }

    @Bean
    public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
        return config.getAuthenticationManager();
    }

    @Bean
    public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
        http
            .csrf(csrf -> csrf.disable())
            .cors(Customizer.withDefaults())
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers(HttpMethod.POST, "/login").permitAll()
                .requestMatchers("/error").permitAll()
                .anyRequest().authenticated())
            .exceptionHandling(ex -> ex
                .authenticationEntryPoint(exceptionHandler))
            .addFilterBefore(authenticationFilter, UsernamePasswordAuthenticationFilter.class);
        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration config = new CorsConfiguration();
        config.setAllowedOrigins(List.of("http://localhost:5173"));
        config.setAllowedMethods(List.of("*"));
        config.setAllowedHeaders(List.of("*"));
        config.setExposedHeaders(List.of("Authorization"));
        config.setAllowCredentials(false);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", config);
        return source;
    }
}

Reading it against the Spring Boot 2 version:

  • AuthenticationManager. The inherited authenticationManager() method is gone with the adapter. Ask Spring's AuthenticationConfiguration for the manager it already built from your UserDetailsService and PasswordEncoder beans. LoginController keeps injecting it exactly as before.
  • The filter chain. It has the same rules in the same order. The only difference is that each section is a lambda, so every .and() disappears.
  • requestMatchers("/error").permitAll(). This line is new, and I explain it in the gotchas section below.
  • CORS. The original allows every origin and calls applyPermitDefaultValues(). I allow only the frontend's origin: http://localhost:5173 is the default for Vite, which current React tutorials use (Create React App used port 3000). setExposedHeaders lets browser JavaScript read the Authorization header that carries the token. Setting the same header in LoginController also works.

The other classes need only small edits:

  • AuthenticationFilter and AuthEntryPoint: change javax.servlet.* imports to jakarta.servlet.*.
  • AccountCredentials can become a one-line Java record: public record AccountCredentials(String username, String password) {}. If you do this, call credentials.username() instead of credentials.getUsername() in the controller.
  • LoginController: @RequestMapping(value="/login", method=RequestMethod.POST) still works, but @PostMapping("/login") is the usual way to write it now.

The JWT library moved too#

The Spring Boot 2 code uses jjwt 0.11.2. In 0.13.0, the builder and parser methods were renamed, and the old ones are either deprecated or removed:

jjwt 0.11jjwt 0.13
Keys.secretKeyFor(SignatureAlgorithm.HS256)Jwts.SIG.HS256.key().build() (the old method is deprecated)
KeySecretKey
.setSubject(username).subject(username)
.setExpiration(date).expiration(date)
Jwts.parserBuilder()Jwts.parser() (parserBuilder no longer exists)
.setSigningKey(key).verifyWith(key)
.parseClaimsJws(token).parseSignedClaims(token)
.getBody().getPayload()

The updated JwtService also adds a small safeguard that I explain next:

@Component
public class JwtService {
    static final long EXPIRATIONTIME = 86_400_000; // 1 day in ms
    static final String PREFIX = "Bearer ";
    // Demo only: a new key on every restart. Load it from configuration in production.
    static final SecretKey key = Jwts.SIG.HS256.key().build();

    public String getToken(String username) {
        return Jwts.builder()
            .subject(username)
            .expiration(new Date(System.currentTimeMillis() + EXPIRATIONTIME))
            .signWith(key)
            .compact();
    }

    public String getAuthUser(HttpServletRequest request) {
        String token = request.getHeader(HttpHeaders.AUTHORIZATION);
        if (token == null || !token.startsWith(PREFIX)) {
            return null;
        }
        try {
            return Jwts.parser()
                .verifyWith(key)
                .build()
                .parseSignedClaims(token.substring(PREFIX.length()))
                .getPayload()
                .getSubject();
        } catch (JwtException | IllegalArgumentException e) {
            return null;
        }
    }
}

In AuthenticationFilter, only set the authentication when getAuthUser actually returned a user:

String user = jwtService.getAuthUser(request);
if (user != null) {
    Authentication authentication = new UsernamePasswordAuthenticationToken(
        user, null, Collections.emptyList());
    SecurityContextHolder.getContext().setAuthentication(authentication);
}

Two things I noticed while testing#

Once the code compiled, I started the app and sent requests with curl. Most results were exactly as expected. Two needed a closer look, and both were hidden behind a status code that looked correct.

1. A tampered token looked like a clean 401, but was really a crash#

I added one character to a valid token and sent it. The response was 401 Unauthorized, which is what you would expect. The log said otherwise:

ERROR ... Servlet.service() for servlet [dispatcherServlet] threw exception
io.jsonwebtoken.security.SignatureException: JWT signature does not match locally computed signature.

The original getAuthUser keeps things short and leaves out error handling. The side effect is that a bad, expired or malformed token throws an exception out of the filter, which should be a 500 Internal Server Error. On Spring Boot 4, the 401 only appears because the crash is forwarded to Spring Boot's /error page, which Spring Security also protects. The real problem was hidden behind a status code that happened to look right.

The fix is the try/catch in getAuthUser above. A token that fails verification now means "no user", the request continues unauthenticated, and AuthEntryPoint returns a real 401. After the change, the log showed no exceptions at all.

2. Every error became 401#

The same mechanism hides other errors. After hiding the users repository from Spring Data REST, I requested /api/users with a valid admin token and expected 404 Not Found. I got 401.

The 404 itself was correct. It was forwarded to /error, and since Spring Security 6, the authorization rules apply to that internal forward too. This did not happen on Spring Boot 2.6, which is why older code never needed to mention /error. My filter does not run again on the error forward, so there is no user, and Spring Security blocks the error page. Allowing it through fixes the problem:

.requestMatchers("/error").permitAll()

After that change, /api/users returned 404 as it should. This matters as soon as you build the React frontend, because a 404 or a 400 that shows up as 401 sends you looking for a login bug that does not exist.

Testing it yourself#

You can test with Postman, or with these curl commands from a terminal. Here are the results I got on Spring Boot 4.1.1:

# No token: rejected
curl -i http://localhost:8080/api/cars
# 401

# Wrong password: rejected with a message from AuthEntryPoint
curl -i -H "Content-Type: application/json" \
  -d '{"username":"user","password":"nope"}' http://localhost:8080/login
# 401  Error: Bad credentials

# Correct password: the token comes back in the Authorization header
curl -i -H "Content-Type: application/json" \
  -d '{"username":"user","password":"user"}' http://localhost:8080/login
# 200  Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...

# Use the token
curl -H "Authorization: Bearer <token>" http://localhost:8080/api/cars
# 200 with the car list
RequestStatus
/api/cars with no token401
/login with the wrong password401, Error: Bad credentials
/login with the right password200, token in the Authorization header
/api/cars with the token200
/api/users with the token404 (hidden repository)
/api/cars with a tampered token401, nothing in the error log
CORS preflight from http://localhost:5173200, origin allowed

What to take away#

  • WebSecurityConfigurerAdapter is not deprecated any more. It is gone, and has been since Spring Boot 3. Every configure(...) override becomes a @Bean.
  • On Spring Boot 4, the lambda configuration style is the only one that compiles. If a guide uses .and(), authorizeRequests() or antMatchers(), it was written for an older version.
  • You no longer register users with an AuthenticationManagerBuilder. One UserDetailsService bean plus one PasswordEncoder bean is enough, and AuthenticationConfiguration gives you the AuthenticationManager.
  • Check other libraries' versions too. jjwt renamed its builder and parser methods, and javax became jakarta.
  • Test the failure cases, not just the success. Both issues I found produced a 401 that looked correct and were only visible in the server log.

The ideas behind this setup have not changed: a stateless backend, a login endpoint that issues a token, and a filter that checks it on every request. Only the syntax for expressing them has.

Found this useful? Share it.

More on Technology

All writing →