7 min read

Spring security mutliple authentication providers [NEW - Spring boot 3+]

In this post we will see how we can have multiple different authentication mechanisms with Spring Security.

When we want our application to support multiple different authentication techniques, we need to have multiple AuthenticationProvider registered with the ProviderManager.

In Spring Security the authentication flow goes like this:

  • Request goes through security filter chain filters where some object of type AuthenticationToken might be constructed
  • This object is then passed along to the AuthenticationManager (ProviderManager by default) and this manager decides which provider it's going to use based on the type of token (Authentication) it received
  • The chosen AuthenticationProvider then does its job of authenticating the user based on the data it receives, and it fills in the user details inside the AuthenticationToken and returns it back to the filter
  • Filter takes the token and sets the Authentication to the context holder

In the above diagram you can see an overview of how this works based on the example we will build.

  1. Instance of TenantAuthenticationToken (Authentication) is passed from the filter the the AuthenticationManager.
  2. AuthenticationManager is by default an instance of ProviderManager which holds a list of AuthenticationProviders. It will go through each provider and check if it supports this Authentication.
  3. Supported provider will attempt to authenticate and fill in the authentication details into the Authentication (TenantAuthenticationToken) object and it will return this information to the Filter.
  4. Filter will set the received Authentication into the SecurityContextHolder

Let's summarize what some of these interfaces represent

Authentication - Represents the token for an authentication request or for an authenticated principal once the request has been processed by the AuthenticationManager.authenticate(Authentication) method.

SecurityFilterChain - It's an object that holds the filters. You can think of filters like middleware, before the request reaches your controller it will first go through all your filters (depending if the request matches the filter chain)

SecurityContextHolder - It's a place where your Authentication details live. It uses a ThreadLocal to store the details, so they are accessible only within the scope of a request (thread).

AuthenticationEntrypoint - It defines what should happen in case a request is not authenticated, so for example in case of a form login, it redirects to the login page, or in case of a JWT token it returns a 401 status code.

Let's write some code. For this example I'm using Spring Boot 3.2 which manages version 6.2.0 of Spring Security. This code will not be using database or any kind of persistence, we will just mock some data to keep it as simple as possible.

Let's start with the Filters.



public class JwtAuthenticationFilter extends OncePerRequestFilter {
  private AuthenticationManager authenticationManager;

  public JwtAuthenticationFilter(AuthenticationManager authenticationManager) {
    this.authenticationManager = authenticationManager;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {
    // In case of a jwt you'd want to get the value after Bearer prefix, but this is just an example, we won't use an actual jwt token or actual verification
    String token = request.getHeader("Authorization");
    if(token == null) {
      this.logger.trace("Did not find JWT token in request");
      filterChain.doFilter(request, response);
      return;
    }

    try {
      Authentication authentication = this.authenticationManager.authenticate(new JwtAuthenticationToken(token));
      SecurityContextHolder.getContext().setAuthentication(authentication);
      filterChain.doFilter(request, response);
    } catch (Exception e) {
      this.logger.error("JWT Authentication failed");
      response.setStatus(HttpServletResponse.SC_BAD_REQUEST);
      // If you want to immediatelly return an error response, you can do it like this:
      response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
      // but you can also just let the request go on and let the next filter handle it
      //filterChain.doFilter(request, response);
    }
  }
}




public class TenantAuthenticationFilter extends OncePerRequestFilter {
  private AuthenticationManager authenticationManager;

  public TenantAuthenticationFilter(AuthenticationManager authenticationManager) {
    this.authenticationManager = authenticationManager;
  }

  @Override
  protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
      FilterChain filterChain) throws ServletException, IOException {

    String clientId = request.getHeader("X-Client-Id");
    String clientSecret = request.getHeader("X-Client-Secret");
    if(clientId == null || clientSecret == null) {
      this.logger.trace("Did not find client id or client secret in request");
      filterChain.doFilter(request, response);
      return;
    }

    try {
      Authentication authentication = this.authenticationManager.authenticate(new TenantAuthenticationToken(clientId, clientSecret));
      SecurityContextHolder.getContext().setAuthentication(authentication);
      filterChain.doFilter(request, response);
    } catch (Exception e) {
      this.logger.error("Tenant Authentication failed");
      response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
      // If you want to immediatelly return an error response, you can do it like this:
      // response.sendError(HttpStatus.UNAUTHORIZED.value(), HttpStatus.UNAUTHORIZED.getReasonPhrase());
      // but you can also just let the request go on
      filterChain.doFilter(request, response);
    }
  }
}


As you can see these filters expect different things, one is looking for jwt token and the other is looking for tenant id and secret. Both are delegating the authentication to the authentication manager and setting whatever it returns to the SecurityContextHolder and continuing the filter chain.

When an authentication manager returns an Authentication that isn't fully authenticated (isAuthenticated = false) then the request fill end in the last filter in the chain AuthorizationFilter, because it will see the request requires authenticated user and it doesn't have one.

Next let's see the tokens



public class JwtAuthenticationToken implements Authentication {
  private boolean isAuthenticated;
  private AppUser userDetails;
  @Getter
  private final String token;

  // Constructor to be used pre-authentication
  public JwtAuthenticationToken(String token) {
    this.token = token;
  }

  // Constructor to be used after successful authentication
  public JwtAuthenticationToken(String token, AppUser userDetails) {
    this.token = token;
    this.userDetails = userDetails;
    this.isAuthenticated = true;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  public Object getCredentials() {
    return token;
  }

  @Override
  public Object getDetails() {
    return userDetails;
  }

  @Override
  public Object getPrincipal() {
    return userDetails;
  }

  @Override
  public boolean isAuthenticated() {
    return isAuthenticated;
  }

  @Override
  public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
    throw new IllegalArgumentException("Not supported, use constructor");
  }

  @Override
  public String getName() {
    return userDetails.getUsername();
  }
}




public class TenantAuthenticationToken implements Authentication {

  private boolean isAuthenticated;
  private AppTenant userDetails;
  @Getter
  private String clientId;
  @Getter
  private String clientSecret;

  // Constructor to be used pre-authentication
  public TenantAuthenticationToken(String clientId, String clientSecret) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
  }

  // Constructor to be used after successful authentication
  public TenantAuthenticationToken(AppTenant tenant) {
    this.userDetails = tenant;
    this.isAuthenticated = true;
  }

  @Override
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  public Object getCredentials() {
    return null;
  }

  @Override
  public Object getDetails() {
    return userDetails;
  }

  @Override
  public Object getPrincipal() {
    return userDetails;
  }

  @Override
  public boolean isAuthenticated() {
    return isAuthenticated;
  }

  @Override
  public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
    throw new IllegalArgumentException("Not supported, use constructor");
  }

  @Override
  public String getName() {
    return userDetails.getUsername();
  }
}


As you can see they both implement the Authentication interface, but they hold different data. JwtAuthenticationToken holds user of type AppUser and TenantAuthentication token holds user of type AppTenant.

Lets see what those 2 are:



public class AppTenant implements UserDetails {

  public AppTenant(String name, String tenantId) {
    this.tenantId = tenantId;
    this.name = name;
  }

  private String name;
  @Getter
  private String type = "tenant";
  @Getter
  private String tenantId;

  @Override
  @JsonIgnore
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  @JsonIgnore
  public String getPassword() {
    return null;
  }

  @Override
  @JsonProperty("name")
  public String getUsername() {
    return name;
  }

  @Override
  @JsonIgnore
  public boolean isAccountNonExpired() {
    return false;
  }

  @Override
  @JsonIgnore
  public boolean isAccountNonLocked() {
    return false;
  }

  @Override
  @JsonIgnore
  public boolean isCredentialsNonExpired() {
    return false;
  }

  @Override
  @JsonIgnore
  public boolean isEnabled() {
    return false;
  }
}




public class AppUser implements UserDetails {

  public AppUser(String username) {
    this.username = username;
  }

  private String username;
  private String password;
  @Getter
  private String type = "user";

  @Override
  @JsonIgnore
  public Collection<? extends GrantedAuthority> getAuthorities() {
    return null;
  }

  @Override
  @JsonIgnore
  public String getPassword() {
    return null;
  }

  @Override
  public String getUsername() {
    return username;
  }

  @Override
  @JsonIgnore
  public boolean isAccountNonExpired() {
    return false;
  }

  @Override
  @JsonIgnore
  public boolean isAccountNonLocked() {
    return false;
  }

  @Override
  @JsonIgnore
  public boolean isCredentialsNonExpired() {
    return false;
  }

  @Override
  @JsonIgnore
  public boolean isEnabled() {
    return false;
  }
}


As you can see both classes implement the UserDetails, although we didn't need to do this. You will find that spring security already has a token called UsernamePasswordAuthenticationToken which works with their DaoAuthenticationProvider and that depends on this interface. They provide all of this and let you just provide the service of type UserDetailsService and that's why you will see it used in a lot of tutorials. But it's not required to use that interface unless you're building providing that UserDetailsService yourself.

Next lets see the providers



public class JwtAuthenticationProvider implements AuthenticationProvider {
  private final JwtAuthService authService;

  public JwtAuthenticationProvider(JwtAuthService service) {
    this.authService = service;
  }

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    return authService.authenticate((JwtAuthenticationToken) authentication);
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return authentication.equals(JwtAuthenticationToken.class);
  }
}




public class TenantAuthenticationProvider implements AuthenticationProvider {

  private final TenantAuthenticationService authService;
  public TenantAuthenticationProvider(TenantAuthenticationService authService) {
    this.authService = authService;
  }

  @Override
  public Authentication authenticate(Authentication authentication) throws AuthenticationException {
    return authService.authenticate((TenantAuthenticationToken) authentication);
  }

  @Override
  public boolean supports(Class<?> authentication) {
    return authentication.equals(TenantAuthenticationToken.class);
  }
}


As you can see both implement AuthenticationProvider, and the supports works based on the class of the token that is provided. Each delegate the work to a service. 

Lets see those services



@Service
public class JwtAuthService {

  public JwtAuthenticationToken authenticate(JwtAuthenticationToken jwtAuthenticationToken) {
    // You would usually verify the token, fetch the user details based on the token and set it to the user object
    // but for this demo, we will just populate the user object with dummy data
    if (jwtAuthenticationToken.getToken().equals("valid-token")) {
      AppUser authenticatedUser = new AppUser("John Doe");
      return new JwtAuthenticationToken(jwtAuthenticationToken.getToken(), authenticatedUser);
    }

    return jwtAuthenticationToken;
  }

}




@Service
public class TenantAuthenticationService {

  public TenantAuthenticationToken authenticate(TenantAuthenticationToken authentication) {
    // You would probably get the tenant from the database based on provided credentials and authenticate
    // but for this demo, we will just populate the tenant object with dummy data if clientId = tenant-1 and clientSecret = secret
    if (authentication.getClientId().equals("123456") && authentication.getClientSecret().equals("secret")) {
      AppTenant tenant = new AppTenant("Tenant 1", "123456");
      return new TenantAuthenticationToken(tenant);
    }

    return authentication;
  }

}


This is where you would have your custom logic to authenticate the users. But for the sake of simplicity we just hardcoded some scenario in which we will have an authenticated user. In case of a jwt we need to supply Authorization header with value valid-token, and in case of tenant authentication we need to provide X-Client-Id header with value of 123456 and X-Client-Secret with value of secret.

And finally lets see the configuration class



@Configuration
@EnableWebSecurity
public class SecurityConfig {

  @Autowired
  private JwtAuthService jwtAuthService;

  @Autowired
  private TenantAuthenticationService tenantAuthService;

  @Bean
  public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http
        .authorizeHttpRequests((authorize) -> {
          authorize.anyRequest().authenticated();
        })
        .formLogin(AbstractHttpConfigurer::disable);

    http.addFilterBefore(new JwtAuthenticationFilter(authenticationManager()), LogoutFilter.class);
    http.addFilterBefore(new TenantAuthenticationFilter(authenticationManager()), JwtAuthenticationFilter.class);

    return http.build();
  }

  @Bean
  public AuthenticationManager authenticationManager() {
    JwtAuthenticationProvider jwtAuthenticationProvider = new JwtAuthenticationProvider(jwtAuthService);
    TenantAuthenticationProvider tenantAuthenticationProvider = new TenantAuthenticationProvider(tenantAuthService);
    return new ProviderManager(jwtAuthenticationProvider, tenantAuthenticationProvider);
  }

}


This should really be self explanatory.

When you start an application you should see a log like this:



Will secure any request with [org.springframework.security.web.session.DisableEncodeUrlFilter@5478ce1e, org.springframework.security.web.context.request.async.WebAsyncManagerIntegrationFilter@5edc70ed, org.springframework.security.web.context.SecurityContextHolderFilter@2dc3271b, org.springframework.security.web.header.HeaderWriterFilter@17f3eefb, org.springframework.web.filter.CorsFilter@4317850d, org.springframework.security.web.csrf.CsrfFilter@b887730, com.ssmap.demo.security.auth.filter.TenantAuthenticationFilter@7e4c72d6, com.ssmap.demo.security.auth.filter.JwtAuthenticationFilter@6cd64b3f, org.springframework.security.web.authentication.logout.LogoutFilter@1ab5f08a, org.springframework.security.web.savedrequest.RequestCacheAwareFilter@3d0035d2, org.springframework.security.web.servletapi.SecurityContextHolderAwareRequestFilter@2bfb6b49, org.springframework.security.web.authentication.AnonymousAuthenticationFilter@51b01550, org.springframework.security.web.access.ExceptionTranslationFilter@64d4f7c7, org.springframework.security.web.access.intercept.AuthorizationFilter@4779aae6]


Somewhere in the middle you can see our two custom filters.

Let's now test this.

This is a controller I've made for testing



@RestController
@RequestMapping("/api/users")
public class UsersController {

  @RequestMapping("/me")
  public ResponseEntity<Object> getUser() {
    Object appUser = SecurityContextHolder.getContext().getAuthentication().getDetails();
    return ResponseEntity.ok(appUser);
  }

}


If you provide both then the last one will override the previous one. But you should have a clear understanding now and you should now how to control what should happen in that case.

Let me know in the comments if you'd like me to build on top of this and go into more details, or you would like to see more spring security related stuff on this blog.

Source code will be on github: https://github.com/NerminKarapandzic/spring-security-multiple-authentication-providers-demo