Reactive Spring Security is a parallel stack to the servlet one, redesigned because the whole servlet model — ThreadLocal-based SecurityContextHolder, Filter chains, blocking UserDetailsService — assumes a thread owns the request. On WebFlux that assumption is false. The core swap is ThreadLocal for the Reactor Context, and blocking interfaces for Mono-returning ones.
Why ThreadLocal breaks
In MVC, SecurityContextHolder stores the authenticated principal in a ThreadLocal, which works because one thread handles the whole request. In WebFlux a request hops across event-loop threads as it suspends and resumes on I/O, so a ThreadLocal set early is gone (or wrong) later. The fix: carry the security context inside the reactive pipeline via the Reactor Context, which travels with the subscription, not the thread.
The reactive equivalents
// servlet -> reactive
SecurityContextHolder -> ReactiveSecurityContextHolder // returns Mono<SecurityContext>
AuthenticationManager -> ReactiveAuthenticationManager // Mono<Authentication>
UserDetailsService -> ReactiveUserDetailsService // Mono<UserDetails>
WebSecurityConfigurer -> SecurityWebFilterChain // configured via ServerHttpSecurity
ReactiveSecurityContextHolder exposes the current principal as a Mono, so you compose it into your pipeline instead of reading a static field:
ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.map(Authentication::getName)
.flatMap(username -> repo.findByOwner(username));
ReactiveAuthenticationManager authenticates non-blockingly, returning Mono<Authentication> — its implementations chain to a ReactiveUserDetailsService (also Mono-returning) so even the user lookup doesn't block the event loop.
Configuration shape
You configure a SecurityWebFilterChain bean using ServerHttpSecurity (the reactive analogue of HttpSecurity):
@Bean
SecurityWebFilterChain chain(ServerHttpSecurity http) {
return http
.authorizeExchange(e -> e.pathMatchers("/admin/**").hasRole("ADMIN")
.anyExchange().authenticated())
.oauth2ResourceServer(o -> o.jwt(Customizer.withDefaults()))
.build();
}
Note authorizeExchange (not authorizeHttpRequests) and WebFilter (not servlet Filter).