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-webandwebfluxare present, Tomcat wins — it appears to work but you’re not reactive SecurityContextHolder(ThreadLocal) silently returnsnullin reactive context — no error, just no authentication- A servlet-based
UserDetailsServicebean 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(extendsOncePerRequestFilter— servlet API)CustomAuthenticationProvider(extendsDaoAuthenticationProvider— calls blockingloadUserByUsername())Member.javaandToken.javaJPA entities (auth-service has no database)
Migration Was Done in Two Steps
Step 0+1 (commit f2d9bf2): Security Layer Swap
- Removed
spring-boot-starter-webfrom 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, returnsMono<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.
