Spring Security Migration: Servlet to WebFlux (Reactive)

Overview

Migrating Spring Security from the Servlet stack (Tomcat) to the Reactive stack (WebFlux/Netty) is the hardest part of a Servlet-to-WebFlux migration. Spring Security has two completely separate API surfaces that look similar but are fundamentally incompatible.


The Chain of Issues During Migration

Issue 1: block() in ServiceClient

The original ServiceClient used WebClient but called .block() on the reactive chain. When auth-service was switched to run on Netty (reactive server), .block() is not allowed on the Reactor event loop thread. This meant member-service was never actually called — the request died at .block() with a runtime error.

Fix: Added postReactive() and getReactive() methods to ServiceClient that return Mono<ServiceResponse<T>> instead of calling .block().

Issue 2: CustomUserDetailService Not Wired Correctly

After removing .block() from ServiceClient, the entire Spring Security chain had to be rebuilt simultaneously because all 6 layers changed at once:

Layer Servlet (Tomcat) Reactive (Netty)
Annotation @EnableWebSecurity @EnableWebFluxSecurity
Filter Chain SecurityFilterChain SecurityWebFilterChain
Security DSL HttpSecurity ServerHttpSecurity
JWT Filter JwtAuthFilter (OncePerRequestFilter) JwtWebFilter (WebFilter)
Auth Manager AuthenticationManager ReactiveAuthenticationManager
Auth Provider CustomAuthenticationProvider (DaoAuthenticationProvider) Lambda bean in config (no equivalent class)
User Details UserDetailsService / loadUserByUsername() ReactiveUserDetailsService / findByUsername() returns Mono
Security Context SecurityContextHolder (ThreadLocal) ReactiveSecurityContextHolder (Reactor Context)
Request/Response HttpServletRequest / HttpServletResponse ServerWebExchange
Endpoint Config .authorizeHttpRequests() / .requestMatchers() .authorizeExchange() / .pathMatchers()

Why all 6 layers had to change at once:

  • Changing the filter requires the new context holder
  • Changing the config requires the new filter AND new auth manager
  • Changing the auth manager requires the new user details service
  • Changing the user details service requires the reactive service client
  • You can’t mix them — one servlet class left in the chain and the app fails

The temporary .block() problem: Even after rebuilding the security chain, AuthService.issueToken() still called .block() on the ReactiveAuthenticationManager result:

// Step 0+1 commit: temporary .block() — still deadlocks on Netty!
Authentication authentication = reactiveAuthenticationManager
    .authenticate(new UsernamePasswordAuthenticationToken(email, googleId))
    .block();  // FORBIDDEN on Netty event loop thread

This wasn’t fixed until the next commit converted all 4 AuthService methods to return Mono<> and replaced .block() with .flatMap().

Issue 3: Test Data Inserted Directly

Since auth-service couldn’t call member-service yet (due to issues above), user data (“strider”) was inserted directly into PostgreSQL to verify member-service independently. This proved R2DBC and the database layer were working.

Issue 4: Duplicate Data Errors

Once the full flow finally worked end-to-end (auth-service → member-service → PostgreSQL), registration calls hit duplicate key constraint violations because the manually inserted “strider” data already existed.


Why Spring Security Makes This Migration So Hard

1. Silent Failures

  • Auto-configuration silently picks which security stack to activate based on classpath
  • If both spring-boot-starter-web and webflux are present, Tomcat wins — it appears to work but you’re not reactive
  • SecurityContextHolder (ThreadLocal) silently returns null in reactive context — no error, just no authentication
  • A servlet-based UserDetailsService bean may wire successfully but deadlock at runtime on Netty threads

2. No Gradual Migration Path

Unlike other components where you can migrate one piece at a time, Spring Security is all-or-nothing:

block() broken → fixed → CustomUserDetailService broken → fixed
→ manual DB insert to test → full flow works → duplicate data conflicts

Each fix revealed the next layer of problems.

3. Deleted Classes

These servlet-specific classes were completely removed during migration:

  • JwtAuthFilter (extends OncePerRequestFilter — servlet API)
  • CustomAuthenticationProvider (extends DaoAuthenticationProvider — calls blocking loadUserByUsername())
  • Member.java and Token.java JPA entities (auth-service has no database)

Migration Was Done in Two Steps

Step 0+1 (commit f2d9bf2): Security Layer Swap

  • Removed spring-boot-starter-web from pom.xml (Tomcat gone, Netty active)
  • Rebuilt all security classes from Servlet → Reactive equivalents
  • Left a temporary .block() in AuthService to compile

Step 2 (commit ac48da3): Full Reactive AuthService

  • All 4 AuthService methods return Mono<>
  • Replaced .block() with .flatMap() chains
  • Removed blocking post()/get() methods from ServiceClient entirely
  • AuthController accepts Mono<T> request bodies, returns Mono<ResponseEntity<?>>

Key Takeaway

Spring Security’s Servlet-to-Reactive migration requires simultaneous replacement of the entire security chain — from the outermost filter down to the innermost service client call. There is no incremental path. The two stacks share similar names but are completely separate implementations that cannot be mixed.

Leave a Comment

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

Scroll to Top