DEV Community

Ankur Mhatre
Ankur Mhatre

Posted on

Why adding a second `AuthenticationProvider` can silently break Spring Security

I was digging into Spring Security 7.1 internals while rebuilding a custom AuthenticationProvider example, and I found a behavior that I think is easy to miss:

With a single AuthenticationProvider bean, Spring Security can automatically wire it into the shared AuthenticationManager. Add a second provider bean, and that automatic wiring backs off.

That can leave you with an application that starts normally but later fails authentication with ProviderNotFoundException.

The simple case

Suppose you have:

@Bean
public CustomAuthProvider customAuthProvider() {
    return new CustomAuthProvider();
}
Enter fullscreen mode Exit fullscreen mode

Your provider implements:

public class CustomAuthProvider implements AuthenticationProvider {

    @Override
    public Authentication authenticate(Authentication authentication) {
        // custom authentication logic
    }

    @Override
    public boolean supports(Class<?> authenticationType) {
        return authenticationType.equals(
            UsernamePasswordAuthenticationToken.class
        );
    }
}
Enter fullscreen mode Exit fullscreen mode

You might expect that you need to manually create a ProviderManager and expose it as an AuthenticationManager.

You don't.

With one AuthenticationProvider bean, Spring Security's authentication configuration can discover it and add it to the shared authentication manager.

So a simple configuration can be:

@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authenticationProvider(customAuthProvider())
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults());

    return http.build();
}
Enter fullscreen mode Exit fullscreen mode

The explicit .authenticationProvider(...) is important when you want to make the provider-to-chain relationship obvious and deterministic.

Where things get interesting

The problem appears when the application context contains more than one AuthenticationProvider.

Conceptually, Spring's authentication configuration does something like:

String[] beanNames =
    context.getBeanNamesForType(AuthenticationProvider.class);

if (beanNames.length == 1) {
    // automatically register the provider
}
Enter fullscreen mode Exit fullscreen mode

If there are multiple provider beans, automatic discovery backs off.

That means this:

@Bean
AuthenticationProvider providerOne() {
    return new CustomAuthProvider();
}

@Bean
AuthenticationProvider providerTwo() {
    return new AnotherAuthProvider();
}
Enter fullscreen mode Exit fullscreen mode

should not be treated as:

"Spring will automatically put both into my authentication manager."

Instead, configure the providers explicitly where you need them.

For example:

@Bean
SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {
    http
        .authenticationProvider(providerOne())
        .authenticationProvider(providerTwo())
        .authorizeHttpRequests(auth -> auth
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults());

    return http.build();
}
Enter fullscreen mode Exit fullscreen mode

Why this matters with JWT

This becomes even more interesting when the same application has:

  • a custom username/password authentication mechanism
  • a JWT-secured REST API

For example:

/api/**          -> JWT bearer authentication

/admin/**        -> custom username/password authentication
Enter fullscreen mode Exit fullscreen mode

I would use separate SecurityFilterChains:

@Bean
@Order(1)
SecurityFilterChain apiSecurity(
        HttpSecurity http,
        JwtDecoder jwtDecoder) throws Exception {

    http
        .securityMatcher("/api/**")
        .authorizeHttpRequests(auth ->
            auth.anyRequest().authenticated()
        )
        .oauth2ResourceServer(oauth2 ->
            oauth2.jwt(jwt -> jwt.decoder(jwtDecoder))
        )
        .csrf(csrf -> csrf.disable());

    return http.build();
}
Enter fullscreen mode Exit fullscreen mode

And then:

@Bean
@Order(2)
SecurityFilterChain webSecurity(HttpSecurity http) throws Exception {

    http
        .authenticationProvider(customAuthProvider())
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/public/**").permitAll()
            .requestMatchers("/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()
        )
        .formLogin(Customizer.withDefaults());

    return http.build();
}
Enter fullscreen mode Exit fullscreen mode

The important concept is that the security chains are independent request-processing pipelines.

securityMatcher("/api/**") determines which chain handles the request, while @Order determines which matching chain gets the opportunity first.

That gives you a clean separation:

                         HTTP Request
                              |
                              v
                     SecurityFilterChain
                         selection
                              |
             +----------------+----------------+
             |                                 |
        /api/**                           everything else
             |                                 |
             v                                 v
    Bearer token flow                  Form login flow
             |                                 |
             v                                 v
   JwtAuthenticationProvider          CustomAuthProvider
Enter fullscreen mode Exit fullscreen mode

One subtle trap

Don't assume that because your API uses JWT, every authentication mechanism on that chain is JWT-only.

For example, if you add:

http.httpBasic();
Enter fullscreen mode Exit fullscreen mode

to the JWT chain for "temporary testing", you have introduced another authentication mechanism into that chain.

That mechanism can use the application's authentication manager configuration and potentially reach providers you didn't intend to expose through the API.

So the question isn't only:

"Which authentication providers exist?"

It's also:

"Which authentication mechanisms are enabled on each SecurityFilterChain?"

And one more thing: don't write a custom provider unnecessarily

If your requirement is simply:

"My users are stored in a weird database table."

you probably don't need a custom AuthenticationProvider.

If UserDetailsService can represent your user lookup, use it with DaoAuthenticationProvider.

A custom provider makes sense when the actual authentication process is custom:

  • legacy authentication API
  • external RPC service
  • hardware token
  • proprietary credential format
  • verification requiring business logic beyond loading UserDetails
  • authentication that isn't naturally represented by UserDetailsService

Otherwise, you're taking ownership of security code that Spring already knows how to maintain.

The takeaway

The main thing I learned from digging into this was:

Don't think of AuthenticationProvider as something you simply "register globally." Think about which AuthenticationManager and which SecurityFilterChain should actually use it.

For a simple application, automatic wiring can make everything look effortless.

For a larger application with multiple providers or multiple authentication mechanisms, explicit configuration becomes much safer and easier to reason about.

I wrote up the complete walkthrough, including:

  • implementing AuthenticationProvider from scratch
  • ProviderManager and AuthenticationManager internals
  • automatic provider discovery
  • the multiple-provider failure mode
  • PasswordEncoder
  • authentication exceptions
  • multiple SecurityFilterChains
  • custom authentication + JWT
  • common 401/403 and provider-wiring problems

Full article: https://ankurm.com/

I'd be particularly interested in hearing from people running multiple authentication mechanisms in the same Spring Security application: do you prefer explicit provider wiring everywhere, or do you still rely on Spring's automatic configuration when possible?

Top comments (0)