How to Fix Spring WebFlux Controller Method Being Called Multiple Times When Adding a Security Filter

How to Fix Spring WebFlux Controller Method Being Called Multiple Times When Adding a Security Filter

If you’re working with Spring WebFlux and notice that your controller method is being executed multiple times after adding a filter to the security chain, you’re not alone. This issue can lead to unexpected behavior, such as a DecodingException with a “No request body” error and a 400 Bad Request response on the second execution. In this blog post, we’ll explore why this happens and provide a clear, actionable solution to ensure your controller processes requests only once. Optimized for SEO, this guide uses keywords like Spring WebFlux multiple controller calls, security filter chain issues, and fix DecodingException Spring WebFlux to help you find it easily on Google.


The Problem: Controller Method Executed Twice with a Security Filter

Let’s start with a common scenario. You have a Spring WebFlux application with a controller handling a POST request, and everything works fine—until you add a custom filter to the security chain. Suddenly, the controller method runs twice: the first time it processes the request successfully, but the second time it fails with a DecodingException because the request body is no longer available. This behavior disappears when you remove the filter, resulting in a clean 200 OK response.

Here’s an example of the affected controller:

@RestController
class SyncRevokedTokensController(private val syncRevokedTokenService: SyncRevokedTokensService) {

    private val log: Logger = LoggerFactory.getLogger(SyncRevokedTokensController::class.java)

    @PostMapping("/sync-revoked-tokens", consumes = ["application/json"])
    fun syncTokens(@RequestBody request: Mono<SyncRevokedTokens>): Mono<Void> {
        log.info("Received request to sync revoked tokens")
        return request
            .flatMap { syncRequest ->
                if (syncRequest.startSynchronization) {
                    log.info("Starting sync of revoked tokens")
                    syncRevokedTokenService.sync()
                        .doOnSuccess { log.info("Completed sync service operation") }
                        .onErrorResume { error ->
                            log.error("Error during sync operation", error)
                            Mono.error(error)
                        }
                } else {
                    log.info("Synchronization not started as startSynchronization is false")
                    Mono.empty()
                }
            }
            .doOnSuccess { log.info("Successfully completed sync tokens request") }
            .onErrorResume { error ->
                log.error("Error processing sync request", error)
                Mono.error(error)
            }
    }
}

And here’s the security configuration where the issue emerges:

@Bean
@Order(2)
fun syncRevokedTokensFilterChain(http: ServerHttpSecurity): SecurityWebFilterChain {
    return http
        .securityMatcher(ServerWebExchangeMatchers.pathMatchers(HttpMethod.POST, "/sync-revoked-tokens"))
        .authorizeExchange { exchanges -> exchanges.anyExchange().authenticated() }
        .addFilterAt(MfaEndpointsAuthorizationWebFilter(), SecurityWebFiltersOrder.LAST)
        .oauth2ResourceServer { oauth2 ->
            oauth2
                .bearerTokenConverter(ServerBearerTokenAuthenticationConverter())
                .authenticationManagerResolver(customAuthenticationOauth2ManagerResolver())
        }
        .csrf { it.disable() }
        .headers { it.disable() }
        .build()
}

The problematic line is .addFilterAt(MfaEndpointsAuthorizationWebFilter(), SecurityWebFiltersOrder.LAST). When this custom filter is added, the controller method is invoked twice, leading to the DecodingException on the second run.

The Filter Causing the Issue

Here’s the custom filter implementation:

class MfaEndpointsAuthorizationWebFilter : WebFilter {

    companion object {
        private val log = LoggerFactory.getLogger(MfaEndpointsAuthorizationWebFilter::class.java)
    }

    private fun isMfaScopeSupportedEndpoint(path: String): Boolean {
        return true // Simplified for demonstration
    }

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        log.info("Starting MfaEndpointsAuthorizationWebFilter for path: {}", exchange.request.path)
        return ReactiveSecurityContextHolder.getContext()
            .flatMap { securityContext: SecurityContext ->
                if (securityContext.authentication is CustomAuthentication) {
                    val customAuthentication = securityContext.authentication as CustomAuthentication
                    val scope = customAuthentication.tokenScope
                    if ("totp_authenticator" == scope && !isMfaScopeSupportedEndpoint(exchange.request.path.value())) {
                        exchange.response.setStatusCode(HttpStatus.UNAUTHORIZED)
                        return@flatMap Mono.error<Void>(OAuth2AuthenticationException("MFA scope is not supported for called url"))
                    }
                }
                chain.filter(exchange)
            }
            .switchIfEmpty(chain.filter(exchange))
            .onErrorResume(OAuth2AuthenticationException::class.java) { Mono.empty() }
    }
}

The logs confirm the issue: the controller processes the request once successfully, then attempts a second execution, failing with a DecodingException because the request body—a reactive Mono stream—can only be consumed once.


Why Does This Happen?

In Spring WebFlux, the request body is a reactive stream (Mono or Flux) that can only be subscribed to once. If something in the filter chain consumes or triggers multiple executions of the downstream chain (including the controller), the second attempt to read the body fails, resulting in a DecodingException.

The root cause here lies in the filter’s handling of errors. When the MfaEndpointsAuthorizationWebFilter detects an invalid scope (e.g., "totp_authenticator" for an unsupported endpoint), it:

  1. Sets the response status to HttpStatus.UNAUTHORIZED.
  2. Returns a Mono.error with an OAuth2AuthenticationException.
  3. Catches this error with .onErrorResume, returning Mono.empty().

While this seems logical, in practice, returning Mono.error and then converting it to Mono.empty() via .onErrorResume can confuse the WebFlux filter chain. Instead of terminating the chain and sending the response immediately, the framework may proceed to the handler (the controller) after the filter completes with Mono.empty(). This unintended continuation leads to the controller being invoked again, attempting to reprocess an already-consumed request body.

When the filter is removed, the chain executes cleanly, calling the controller only once, which explains the successful 200 OK response.


The Solution: Properly Terminate the Filter Chain

To fix this, we need to ensure the filter terminates the chain correctly when setting an unauthorized response, preventing the controller from being called unnecessarily. Instead of throwing and catching an exception, we can directly complete the response using exchange.response.setComplete(). This approach signals to WebFlux that the response is fully handled, stopping further processing.

Here’s the corrected filter:

class MfaEndpointsAuthorizationWebFilter : WebFilter {

    companion object {
        private val log = LoggerFactory.getLogger(MfaEndpointsAuthorizationWebFilter::class.java)
    }

    private fun isMfaScopeSupportedEndpoint(path: String): Boolean {
        return true // Simplified for demonstration
    }

    override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
        log.info("Starting MfaEndpointsAuthorizationWebFilter for path: {}", exchange.request.path)
        return ReactiveSecurityContextHolder.getContext()
            .flatMap { securityContext: SecurityContext ->
                if (securityContext.authentication is CustomAuthentication) {
                    val customAuthentication = securityContext.authentication as CustomAuthentication
                    val scope = customAuthentication.tokenScope
                    if ("totp_authenticator" == scope && !isMfaScopeSupportedEndpoint(exchange.request.path.value())) {
                        exchange.response.statusCode = HttpStatus.UNAUTHORIZED
                        return@flatMap exchange.response.setComplete()
                    }
                }
                chain.filter(exchange)
            }
            .switchIfEmpty(chain.filter(exchange))
    }
}

Key Changes

  • Replaced Mono.error with setComplete(): When the scope check fails, we set the status to 401 and call exchange.response.setComplete(), which returns a Mono<Void> that completes the response and halts the filter chain.
  • Removed .onErrorResume: Since we’re no longer throwing an exception, there’s no error to catch, simplifying the logic.

With this update, the filter either:

  • Completes the response with a 401 Unauthorized status if the scope is invalid, stopping the chain.
  • Proceeds with chain.filter(exchange) if the request is authorized, calling the controller exactly once.

Verifying the Fix

After applying this change, test your endpoint:

  • Send a POST request to /sync-revoked-tokens with a valid token and body. The controller should log its messages once and return a 200 OK response.
  • Send a request with an invalid scope (e.g., "totp_authenticator" on an unsupported endpoint). The filter should log its execution, return a 401 Unauthorized response, and the controller should not be invoked.

Check your logs to confirm the controller method (syncTokens) is called only once per request.


Additional Troubleshooting Tips

If the issue persists, consider these steps:

  1. Check for Multiple Filter Chains: Autowire List<SecurityWebFilterChain> and log all registered filter chains to ensure only one matches /sync-revoked-tokens. Adjust @Order values if necessary (e.g., use @Order(-101) for higher precedence).
  2. Inspect Logs: Add detailed logging before and after chain.filter(exchange) to trace the execution flow.
  3. Simplify the Filter: Temporarily replace it with a minimal filter (e.g., just logging and calling chain.filter(exchange)) to isolate the problem.

Conclusion

The issue of a Spring WebFlux controller method being called multiple times when adding a security filter stems from improper termination of the filter chain. By replacing Mono.error and .onErrorResume with exchange.response.setComplete(), you ensure the chain stops when intended, preventing duplicate controller invocations and the resulting DecodingException. This solution maintains clean, reactive programming principles while resolving the bug efficiently.

For developers searching for Spring WebFlux security filter issues, controller executed twice, or DecodingException fixes, this guide provides a reliable resolution. Try it in your project, and enjoy a streamlined, single-execution request flow!


Keywords

  • Spring WebFlux controller called multiple times
  • Fix DecodingException Spring WebFlux
  • Security filter chain issues WebFlux
  • Spring reactive filter chain solution
  • Prevent multiple controller calls Spring Boot
  • WebFlux OAuth2 filter problems
  • Spring Security WebFlux troubleshooting

Let me know in the comments if you encounter similar issues or have questions about this fix! Happy coding!

Comments

No comments yet. Why don’t you start the discussion?

Leave a Reply

Your email address will not be published. Required fields are marked *