Reactive Security with Spring WebFlux
Reactive vs. Servlet Security
Spring Security’s standard configuration targets Servlet-based applications (Spring MVC). Reactive applications built with Spring WebFlux run on a non-blocking event loop — there is no thread-per-request model, so ThreadLocal-based SecurityContextHolder does not work.
Spring Security provides a parallel reactive stack:
| Servlet | Reactive |
|---|---|
SecurityFilterChain | SecurityWebFilterChain |
HttpSecurity | ServerHttpSecurity |
SecurityContextHolder | ReactiveSecurityContextHolder |
UserDetailsService | ReactiveUserDetailsService |
AuthenticationManager | ReactiveAuthenticationManager |
@EnableWebSecurity | @EnableWebFluxSecurity |
@EnableMethodSecurity | @EnableReactiveMethodSecurity |
Dependencies
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-security</artifactId>
</dependency>
Spring Security auto-configures reactive security when WebFlux is on the classpath.
Basic Reactive Security Configuration
@Configuration
@EnableWebFluxSecurity
public class ReactiveSecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(auth -> auth
.pathMatchers("/actuator/health").permitAll()
.pathMatchers("/api/public/**").permitAll()
.pathMatchers("/admin/**").hasRole("ADMIN")
.anyExchange().authenticated()
)
.httpBasic(Customizer.withDefaults())
.formLogin(Customizer.withDefaults())
.csrf(csrf -> csrf.disable()) // disable for REST APIs
.build();
}
@Bean
public ReactiveUserDetailsService userDetailsService() {
UserDetails user = User.withUsername("alice")
.password("{noop}password")
.roles("USER")
.build();
return new MapReactiveUserDetailsService(user);
}
}
ServerHttpSecurity API mirrors HttpSecurity closely. The main difference is that everything returns Mono or Flux instead of direct values.
ReactiveUserDetailsService
Implement this interface to load users from a reactive data source (R2DBC, MongoDB reactive, etc.):
@Service
public class ReactiveUserDetailsServiceImpl implements ReactiveUserDetailsService {
private final UserRepository userRepository; // R2DBC-based
private final RoleRepository roleRepository;
@Override
public Mono<UserDetails> findByUsername(String username) {
return userRepository.findByUsername(username)
.switchIfEmpty(Mono.error(new UsernameNotFoundException(username)))
.flatMap(user -> roleRepository.findByUserId(user.getId())
.collectList()
.map(roles -> {
List<GrantedAuthority> authorities = roles.stream()
.map(role -> new SimpleGrantedAuthority(role.getName()))
.collect(Collectors.toList());
return (UserDetails) org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPassword())
.authorities(authorities)
.disabled(!user.isEnabled())
.build();
}));
}
}
JWT Resource Server (Reactive)
For stateless JWT APIs with WebFlux:
@Configuration
@EnableWebFluxSecurity
public class ReactiveSecurityConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(auth -> auth
.pathMatchers(HttpMethod.GET, "/api/public/**").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults())
)
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}
spring.security.oauth2.resourceserver.jwt.jwk-set-uri=https://auth.example.com/.well-known/jwks.json
Custom JWT Converter (Reactive)
@Bean
public ReactiveJwtAuthenticationConverter jwtAuthenticationConverter() {
ReactiveJwtGrantedAuthoritiesConverter grantedAuthoritiesConverter =
new ReactiveJwtGrantedAuthoritiesConverter();
grantedAuthoritiesConverter.setAuthorityPrefix("ROLE_");
grantedAuthoritiesConverter.setAuthoritiesClaimName("roles");
ReactiveJwtAuthenticationConverter converter = new ReactiveJwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(grantedAuthoritiesConverter);
return converter;
}
ReactiveSecurityContextHolder
In WebFlux, access the security context via ReactiveSecurityContextHolder — it stores the context in the reactive subscriber context, not a thread-local:
@RestController
@RequestMapping("/api")
public class UserController {
@GetMapping("/me")
public Mono<UserProfile> getProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> userService.getProfile(auth.getName()));
}
}
Or inject Authentication directly in a WebFlux controller — Spring Security handles the resolution:
@GetMapping("/me")
public Mono<UserProfile> getProfile(Authentication authentication) {
return userService.getProfile(authentication.getName());
}
@GetMapping("/token")
public Mono<Map<String, Object>> tokenInfo(@AuthenticationPrincipal Jwt jwt) {
return Mono.just(jwt.getClaims());
}
Reactive Method Security
Enable method security for reactive methods:
@Configuration
@EnableReactiveMethodSecurity
public class ReactiveMethodSecurityConfig {}
Use @PreAuthorize on methods returning Mono or Flux:
@Service
public class ReactiveDocumentService {
@PreAuthorize("hasRole('ADMIN')")
public Flux<Document> getAllDocuments() {
return documentRepository.findAll();
}
@PreAuthorize("hasRole('ADMIN') or #username == authentication.name")
public Mono<Document> getDocument(String username, Long id) {
return documentRepository.findByIdAndOwner(id, username);
}
@PostAuthorize("returnObject.map(d -> d.ownerId == authentication.name).defaultIfEmpty(false)")
public Mono<Document> getDocumentById(Long id) {
return documentRepository.findById(id);
}
}
@PostAuthorize on reactive methods evaluates after the Mono/Flux completes. The returnObject in SpEL is the Mono<Document>, not the unwrapped Document — this makes @PostAuthorize on reactive methods tricky. Prefer @PreAuthorize or flatMap with manual checks for reactive code.
Custom Reactive Authentication
Implement ReactiveAuthenticationManager for custom authentication logic:
@Component
public class ApiKeyAuthenticationManager implements ReactiveAuthenticationManager {
private final ApiKeyRepository apiKeyRepository;
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
String apiKey = (String) authentication.getCredentials();
return apiKeyRepository.findByKey(apiKey)
.switchIfEmpty(Mono.error(new BadCredentialsException("Invalid API key")))
.filter(key -> !key.isExpired())
.switchIfEmpty(Mono.error(new BadCredentialsException("API key expired")))
.map(key -> {
List<GrantedAuthority> authorities = key.getRoles().stream()
.map(SimpleGrantedAuthority::new)
.collect(Collectors.toList());
return (Authentication) new UsernamePasswordAuthenticationToken(
key.getOwner(), null, authorities
);
});
}
}
// Wire a custom server authentication converter to extract the API key from the header
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http,
ApiKeyAuthenticationManager authManager) {
AuthenticationWebFilter apiKeyFilter = new AuthenticationWebFilter(authManager);
apiKeyFilter.setServerAuthenticationConverter(exchange -> {
String apiKey = exchange.getRequest().getHeaders().getFirst("X-API-Key");
if (apiKey == null) return Mono.empty();
return Mono.just(new UsernamePasswordAuthenticationToken(apiKey, apiKey));
});
return http
.addFilterAt(apiKeyFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.authorizeExchange(auth -> auth.anyExchange().authenticated())
.csrf(csrf -> csrf.disable())
.build();
}
CORS in WebFlux
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.build();
}
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.example.com"));
config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
Testing Reactive Security
Use WebTestClient with Spring Security’s reactive test support:
@SpringBootTest(webEnvironment = SpringBootTest.WebEnvironment.RANDOM_PORT)
class ReactiveSecurityTest {
@Autowired
private WebTestClient webTestClient;
@Test
void unauthenticatedReturns401() {
webTestClient.get()
.uri("/api/documents")
.exchange()
.expectStatus().isUnauthorized();
}
@Test
@WithMockUser(roles = "ADMIN")
void adminCanGetAllDocuments() {
webTestClient
.mutateWith(mockUser().roles("ADMIN"))
.get()
.uri("/api/documents")
.exchange()
.expectStatus().isOk();
}
@Test
void jwtAuthenticatedUserCanGetDocuments() {
webTestClient
.mutateWith(mockJwt().authorities(new SimpleGrantedAuthority("ROLE_USER")))
.get()
.uri("/api/documents")
.exchange()
.expectStatus().isOk();
}
}
Import SecurityMockServerConfigurers.mockUser(), mockJwt(), and mockOAuth2Login() from org.springframework.security.test.web.reactive.server.
Key Differences from Servlet Security
| Concern | Servlet | Reactive |
|---|---|---|
| Config annotation | @EnableWebSecurity | @EnableWebFluxSecurity |
| Filter chain type | SecurityFilterChain | SecurityWebFilterChain |
| Security context | SecurityContextHolder (ThreadLocal) | ReactiveSecurityContextHolder (subscriber context) |
| User details service | UserDetailsService | ReactiveUserDetailsService |
| Authentication manager | AuthenticationManager | ReactiveAuthenticationManager |
| Method security | @EnableMethodSecurity | @EnableReactiveMethodSecurity |
| Test support | mockMvc.perform(...) | webTestClient.mutateWith(...) |
Everything else — authorization rules, JWT configuration, CORS, CSRF — maps directly with a reactive equivalent.
Key Takeaways
- Reactive security uses
@EnableWebFluxSecurityandServerHttpSecurity— not@EnableWebSecurityandHttpSecurity ReactiveSecurityContextHolderstores security context in the subscriber context, not a thread-localReactiveUserDetailsServiceandReactiveAuthenticationManagerreturnMono<UserDetails>andMono<Authentication>- JWT resource server configuration is identical to the servlet version; add it with
.oauth2ResourceServer(oauth2 -> oauth2.jwt(...)) @EnableReactiveMethodSecurityenables@PreAuthorizeonMono/Flux-returning methods- Test with
WebTestClient.mutateWith(mockJwt())— the reactive equivalent of MockMvc’sjwt()post-processor
Next: Spring Security Best Practices and Production Checklist — the final article, consolidating everything into an actionable reference.