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:
- Sets the response status to
HttpStatus.UNAUTHORIZED
. - Returns a
Mono.error
with anOAuth2AuthenticationException
. - Catches this error with
.onErrorResume
, returningMono.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
withsetComplete()
: When the scope check fails, we set the status to 401 and callexchange.response.setComplete()
, which returns aMono<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:
- 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). - Inspect Logs: Add detailed logging before and after
chain.filter(exchange)
to trace the execution flow. - 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!