Web

Spring WebFlux JWT: Fix Login Failures Returning 200

Fix Spring WebFlux JWT login returning 200: why failure handler is skipped and how to return 401 JSON. Let Spring Security handle login or map errors to 401.

1 answer 1 view

Spring WebFlux: my custom ServerAuthenticationFailureHandler isn’t being invoked and failed login attempts return HTTP 200 OK. I have an Authentication_Failure_Handler implementing ServerAuthenticationFailureHandler that writes a 401 JSON response, and I register it in SecurityWebFilterChain via formLogin(authenticationFailureHandler(authenticationfailurehandler)). My /auth/** login endpoint is permittedAll and uses UserService.Login(LoginDto), which calls userRepository.findByUsernameOrEmail(…).filter(password matches).map(user -> new TokenDto(jwtProvider.generateToken(user))). When credentials are wrong the service returns an empty Mono and Postman shows 200 instead of the custom 401 JSON. Why isn’t the ServerAuthenticationFailureHandler triggered, and how can I ensure failed credentials return a 401 JSON in a reactive Spring WebFlux JWT-based authentication flow? Should I switch to Spring Security’s reactive authentication (AuthenticationWebFilter/AuthenticationManager), throw an AuthenticationException or return Mono.error(new BadCredentialsException(…)) from the login flow, or explicitly set the response status in the controller? What is the recommended reactive pattern to handle authentication failures and return a proper 401 JSON response?

Your ServerAuthenticationFailureHandler isn’t being called because Spring Security never runs the authentication filter for a request that your controller already handles as a permitted endpoint — the controller returning an empty Mono is interpreted as a normal 200 OK. For spring webflux and jwt spring security flows you can either let Spring Security handle authentication (AuthenticationWebFilter / ReactiveAuthenticationManager) so your ServerAuthenticationFailureHandler runs, or make your login controller surface failures (throw Mono.error(new BadCredentialsException(…)) or return an explicit 401) and map those to a JSON 401 via exceptionHandling() or a @RestControllerAdvice.


Contents


Problem — Login returns 200 OK and failure handler not invoked (Spring WebFlux + JWT Spring Security)

What’s happening: your POST /auth/login is handled by a controller that returns a Mono. When credentials are wrong your service returns an empty Mono — that becomes an HTTP 200 with no body (or an empty response) because the controller completed successfully but with no value. The Security filter that would call your ServerAuthenticationFailureHandler never ran for that request, so your failure handler was never invoked.

This exact symptom (controller-permitted endpoint bypassing the reactive authentication handlers) is documented in community guides and Q&A: if Spring Security doesn’t “own” the login endpoint (i.e., the controller handles it and it’s permittedAll), AuthenticationWebFilter won’t see the request and a configured failure handler won’t run. See the explanation and examples in the reactive docs and tutorials linked below (official reference and community threads show the same pattern).


How WebFlux reactive authentication is wired (AuthenticationWebFilter)

In reactive Spring Security the filter that processes authentication is typically an AuthenticationWebFilter. That filter delegates success/failure to configured ServerAuthenticationSuccessHandler and ServerAuthenticationFailureHandler implementations; a failure handler receives the WebFilterExchange and an AuthenticationException and returns a Mono to write the response (e.g. 401 JSON). See the AuthenticationWebFilter API and the ServerAuthenticationFailureHandler API for the reactive contract and examples: the AuthenticationWebFilter API docs and ServerAuthenticationFailureHandler API docs. The main point: the filter must actually execute for those handlers to be called.

For REST APIs you typically want authentication failures to produce a json response (json response 401) not a redirect — the reactive reference shows how to favor 401 JSON via exceptionHandling().authenticationEntryPoint(…) rather than redirects. See the WebFlux configuration reference for patterns: Spring Security WebFlux reference.


Why the ServerAuthenticationFailureHandler isn’t invoked (permitAll + controller bypass)

  • permitAll + controller mapping = controller wins: when you permit the path and handle it with a controller, Spring’s normal routing delivers the request to the controller before the authentication filter can take control for “form login” style processing. A controller that returns Mono.empty() produces a 200 OK — nothing in the filter chain created an AuthenticationException to trigger your failure handler. The Natural Programmer tutorial and several StackOverflow threads explain this exact trap and show that a controller-based login will bypass AuthenticationWebFilter handlers unless you remove the controller and let the filter own the route or explicitly surface failures from the controller (throw an exception) so they can be mapped: see this discussion and reproducer in community answers and issues (example: StackOverflow thread showing the same symptom and Natural Programmer notes about permitAll/controller bypass). There are also GitHub issues showing related edge cases in certain Spring Security versions (issue 7782).

So the summary: your failure handler would only run if the authentication filter attempted authentication and produced an AuthenticationException; a controller returning empty doesn’t produce that.


Fix 1 — Let Spring Security handle login (AuthenticationWebFilter / ReactiveAuthenticationManager)

When you want the failure handler to be invoked centrally, make Spring Security own the login endpoint. Two ways:

  1. Use ServerHttpSecurity.formLogin() (or configure AuthenticationWebFilter) so credentials are sent the way the filter expects and the filter runs. Then your ServerAuthenticationFailureHandler will be invoked on failed auth. Example patterns are documented in the WebFlux reference and the AuthenticationWebFilter API doc.

  2. Register a custom AuthenticationWebFilter that handles POST /auth/login and uses a ReactiveAuthenticationManager; attach your ServerAuthenticationFailureHandler to that filter. That keeps authentication logic inside the security filter chain and centralizes success/failure handling.

Note: remove the controller mapping for the login URL (or change its path) if you want formLogin/auth filter to control that path — otherwise the controller will preempt the filter.

Links: see the AuthenticationWebFilter API docs and the WebFlux guide for configuration examples (Spring Security WebFlux reference).

When to pick this: use it if you want Spring Security to run authentication and you prefer centralized handlers for success/failure. If you do this in a REST API, ensure you provide a failure handler that writes JSON (not a redirect).


Fix 2 — Controller-based login: throw AuthenticationException or return Mono.error(…) and map to 401 JSON

If you prefer the common JWT pattern — controller issues token after manual credential check — keep the controller but make it return an error (not Mono.empty) when credentials fail. Then map that error to a 401 JSON in a single place.

Options:

  • Throw an AuthenticationException (e.g. BadCredentialsException) or return Mono.error(new BadCredentialsException(“Invalid credentials”)) from the service pipeline instead of Mono.empty(). That signals a failure and allows you to map that exception to a 401 JSON response using either:
  • a @RestControllerAdvice that maps BadCredentialsException to ResponseEntity.status(401).body(json), or
  • configure security’s exceptionHandling().authenticationEntryPoint(…) to write a JSON 401 (works well for exceptions that reach Spring Security exception handling).

Example pattern inside your service (convert empty to an error):

userRepository.findByUsernameOrEmail(…)
.filter(user -> passwordEncoder.matches(raw, user.getPassword()))
.map(user -> new TokenDto(jwtProvider.generateToken(user)))
.switchIfEmpty(Mono.error(new BadCredentialsException(“Invalid credentials”)));

Then have a global exception mapper:

@RestControllerAdvice
public class GlobalErrorHandler {
@ExceptionHandler(BadCredentialsException.class)
public Mono<ResponseEntity<Map<String,String>>> handleBadCreds(BadCredentialsException ex) {
return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of(“error”,“invalid_credentials”, “message”, ex.getMessage())));
}
}

Why this is often recommended for JWT APIs: you still control token creation in the controller but you get consistent 401 JSON output and you avoid filter-routing complexity. Multiple community posts and tutorials show this approach for REST JWT flows (example: DevGlan tutorial on WebFlux JWT).


Fix 3 — Explicitly return 401 in controller (less preferred)

Simplest, but more ad-hoc: the controller returns a ResponseEntity with 401 when login fails. Example:

@PostMapping(“/auth/login”)
public Mono<ResponseEntity<?>> login(@RequestBody LoginDto dto) {
return userService.login(dto)
.map(token -> ResponseEntity.ok(token))
.defaultIfEmpty(ResponseEntity.status(HttpStatus.UNAUTHORIZED)
.body(Map.of(“error”,“invalid_credentials”)));
}

This works, but it scatters response generation logic into controllers. For a consistent json response structure you usually prefer throwing a specific exception + centralized mapping or letting a failure handler handle it.


Implementation examples (code)

  1. ServerAuthenticationFailureHandler that writes JSON 401
java
public class JwtAuthenticationFailureHandler implements ServerAuthenticationFailureHandler {

 private final ObjectMapper mapper = new ObjectMapper();

 @Override
 public Mono<Void> onAuthenticationFailure(WebFilterExchange exchange, AuthenticationException ex) {
 ServerHttpResponse response = exchange.getExchange().getResponse();
 response.setStatusCode(HttpStatus.UNAUTHORIZED);
 response.getHeaders().setContentType(MediaType.APPLICATION_JSON);

 Map<String,String> body = Map.of("error","invalid_credentials", "message", ex.getMessage());
 byte[] bytes;
 try {
 bytes = mapper.writeValueAsBytes(body);
 } catch (JsonProcessingException e) {
 bytes = "{\"error\":\"invalid_credentials\"}".getBytes(StandardCharsets.UTF_8);
 }
 DataBuffer buffer = response.bufferFactory().wrap(bytes);
 return response.writeWith(Mono.just(buffer));
 }
}
  1. Convert empty Mono to AuthenticationException in service (controller-based login)
java
public Mono<TokenDto> login(LoginDto dto) {
 return userRepository.findByUsernameOrEmail(dto.getUsername())
 .filter(user -> passwordEncoder.matches(dto.getPassword(), user.getPassword()))
 .map(user -> new TokenDto(jwtProvider.generateToken(user)))
 .switchIfEmpty(Mono.error(new BadCredentialsException("Invalid credentials")));
}
  1. SecurityWebFilterChain snippet (use NoOp security context for stateless JWT; example of formLogin approach)
java
@Bean
public SecurityWebFilterChain springSecurityFilterChain(ServerHttpSecurity http,
 JwtAuthenticationFailureHandler failureHandler,
 JwtAuthenticationSuccessHandler successHandler) {

 return http
 .csrf().disable()
 .securityContextRepository(NoOpServerSecurityContextRepository.getInstance()) // stateless
 .authorizeExchange()
 .pathMatchers("/auth/**").permitAll() // allow access but don't let a controller preempt formLogin if you plan to use formLogin
 .anyExchange().authenticated()
 .and()
 .formLogin()
 .authenticationSuccessHandler(successHandler)
 .authenticationFailureHandler(failureHandler) // only runs when Spring Security handles the form login
 .and()
 .build();
}

If you’re using a custom AuthenticationWebFilter, register it so it runs on the login path instead of a controller. If you keep the controller, use the switchIfEmpty(Mono.error(…)) + @RestControllerAdvice approach.

Links for detailed APIs and examples: AuthenticationWebFilter API docs, ServerAuthenticationFailureHandler API docs, and community examples (StackOverflow and GitHub issues) for edge-cases.


Testing & debugging tips (logs, curl/Postman)

  • Reproduce with curl/Postman and inspect status explicitly:
    curl -i -X POST -H “Content-Type: application/json” -d ‘{“username”:“bad”, “password”:“bad”}’ http://localhost:8080/auth/login

  • Enable security debug/trace to watch the filter chain:
    application.yml
    logging:
    level:
    org.springframework.security: DEBUG
    reactor.netty: WARN

Look for AuthenticationWebFilter logs to see whether it executed for the request. If you never see it, the controller likely handled the request first.

  • Add quick logging hooks inside controller/service pipeline (doOnNext/doOnError) to verify whether your service returned Mono.empty() vs Mono.error.

  • Verify Spring Security version: a few GitHub issues document behavior differences/bugs in specific versions (issue 7782, issue 13130). If behavior looks incorrect for your version, search the project issues.


Best practices for stateless JWT flows (webflux spring boot)

  • For APIs use a controller-based token endpoint OR a custom AuthenticationWebFilter — pick one and don’t let a controller silently preempt the filter for the same path.
  • If you choose controller-based login, convert empty results to errors (Mono.error(new BadCredentialsException(…))) and map them centrally with @RestControllerAdvice to return consistent 401 JSON.
  • If you choose filter-based auth, remove the controller mapping for the login path so AuthenticationWebFilter/formLogin owns that path; then attach a ServerAuthenticationFailureHandler that writes 401 JSON.
  • Use NoOpServerSecurityContextRepository for stateless JWT APIs:
    securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
  • Disable CSRF for stateless APIs:
    csrf().disable()
  • Keep a single JSON error shape for all auth failures (401) so clients can parse errors reliably.
  • Use BCrypt for password hashing and rotate keys for JWT signing.
  • Test with debug logs to ensure the filter chain behaves as expected.

For practical tutorials and patterns see the DevGlan WebFlux JWT tutorial and Baeldung’s guidance on custom auth-failure handlers: DevGlan JWT guide, Baeldung custom AuthenticationFailureHandler.


Sources


Conclusion

In short: your failure handler wasn’t invoked because the login controller (permitAll) handled the request and produced an empty Mono → 200 OK, so AuthenticationWebFilter never ran. For spring webflux and jwt spring security flows either let Spring Security own the login endpoint (AuthenticationWebFilter/formLogin) so the ServerAuthenticationFailureHandler executes, or keep the controller but surface failures (throw Mono.error(new BadCredentialsException(…)) or return an explicit 401) and map them to a JSON 401 using a @RestControllerAdvice or exceptionHandling(). Either approach works; choose the one that gives you centralized, consistent 401 JSON responses and simpler client behavior.

Authors
Verified by moderation
Moderation
Spring WebFlux JWT: Fix Login Failures Returning 200