Reactive Security: Spring Security with WebFlux
Spring Security's servlet-based model is built on ThreadLocal and Filter chains: neither of which works in a non-blocking reactive stack. Spring Security provides a parallel reactive model: WebFilter instead of Filter, ServerHttpSecurity instead of HttpSecurity, and Mono-returning authentication managers instead of synchronous ones. The API is similar but the wiring is different, and blocking calls inside a security filter will deadlock a WebFlux application.
Contents
Dependency
Add the following to your pom.xmlspring-boot-starter-webfluxspring-boot-starter-web
<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>
<!-- JWT (JJWT) -->
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-api</artifactId>
<version>0.12.5</version>
</dependency>
<dependency>
<groupId>io.jsonwebtoken</groupId>
<artifactId>jjwt-impl</artifactId>
<version>0.12.5</version>
<scope>runtime</scope>
</dependency>
ServerHttpSecurity: Basic Configuration
In a WebFlux application, security is configured via ServerHttpSecurityHttpSecuritySecurityWebFilterChainSecurityFilterChain
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable) // stateless API — no CSRF needed
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/actuator/health").permitAll()
.pathMatchers(HttpMethod.POST, "/auth/login").permitAll()
.pathMatchers("/admin/**").hasRole("ADMIN")
.anyExchange().authenticated()
)
.build();
}
}
ReactiveUserDetailsService
ReactiveUserDetailsServiceUserDetailsServiceMono<UserDetails>
@Service
public class ReactiveUserService implements ReactiveUserDetailsService {
private final UserRepository userRepo; // R2DBC or other reactive repo
@Override
public Mono<UserDetails> findByUsername(String username) {
return userRepo.findByUsername(username)
.switchIfEmpty(Mono.error(new UsernameNotFoundException(username)))
.map(user -> org.springframework.security.core.userdetails.User
.withUsername(user.getUsername())
.password(user.getPasswordHash())
.roles(user.getRoles().toArray(String[]::new))
.accountExpired(!user.isActive())
.build());
}
}
JWT Authentication WebFilter
A WebFilterFilterAuthorizationSecurityContext
@Component
public class JwtAuthenticationWebFilter implements WebFilter {
private final JwtService jwtService;
private final ReactiveUserDetailsService userDetailsService;
@Override
public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
String authHeader = exchange.getRequest()
.getHeaders().getFirst(HttpHeaders.AUTHORIZATION);
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return chain.filter(exchange); // no token — continue unauthenticated
}
String token = authHeader.substring(7);
return Mono.just(token)
.filter(jwtService::isValid)
.flatMap(t -> {
String username = jwtService.extractUsername(t);
return userDetailsService.findByUsername(username)
.map(userDetails -> new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()));
})
.flatMap(auth -> {
SecurityContext ctx = new SecurityContextImpl(auth);
return chain.filter(exchange)
.contextWrite(ReactiveSecurityContextHolder.withSecurityContext(Mono.just(ctx)));
})
.switchIfEmpty(chain.filter(exchange)); // invalid token — continue unauthenticated
}
}
Wiring JWT into SecurityFilterChain
Register the JWT WebFilterSecurityWebFiltersOrder.AUTHENTICATION
@Configuration
@EnableWebFluxSecurity
public class SecurityConfig {
private final JwtAuthenticationWebFilter jwtFilter;
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.httpBasic(ServerHttpSecurity.HttpBasicSpec::disable)
.formLogin(ServerHttpSecurity.FormLoginSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/auth/**").permitAll()
.pathMatchers("/actuator/health").permitAll()
.anyExchange().authenticated()
)
.addFilterAt(jwtFilter, SecurityWebFiltersOrder.AUTHENTICATION)
.build();
}
@Bean
public ReactiveAuthenticationManager authenticationManager(
ReactiveUserDetailsService userDetailsService,
PasswordEncoder encoder) {
UserDetailsRepositoryReactiveAuthenticationManager manager =
new UserDetailsRepositoryReactiveAuthenticationManager(userDetailsService);
manager.setPasswordEncoder(encoder);
return manager;
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
}
Reactive Method Security
Method-level security annotations (@PreAuthorize@PostAuthorize@EnableReactiveMethodSecurityMonoFlux
@Configuration
@EnableReactiveMethodSecurity
public class MethodSecurityConfig { }
@Service
public class OrderService {
// Only ADMIN can delete any order
@PreAuthorize("hasRole('ADMIN')")
public Mono<Void> deleteOrder(Long orderId) {
return orderRepository.deleteById(orderId);
}
// Users can only see their own orders
@PreAuthorize("#userId == authentication.name")
public Flux<Order> findByUser(String userId) {
return orderRepository.findByUserId(userId);
}
// PostAuthorize — check the result after it's loaded
@PostAuthorize("returnObject.map(o -> o.ownerId == authentication.name).defaultIfEmpty(true)")
public Mono<Order> findById(Long id) {
return orderRepository.findById(id);
}
}
OAuth2 Resource Server
For services that accept tokens issued by an external Identity Provider (Keycloak, Auth0, Okta, AWS Cognito), configure Spring Security as an OAuth2 resource server. It validates the JWT signature using the provider's JWKS endpoint and populates the security context automatically: no custom filter needed.
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://your-idp.com/realms/myrealm
# Spring fetches the JWKS from issuer-uri/.well-known/openid-configuration
@Configuration
@EnableWebFluxSecurity
public class ResourceServerConfig {
@Bean
public SecurityWebFilterChain securityFilterChain(ServerHttpSecurity http) {
return http
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/public/**").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtConverter())))
.build();
}
// Map custom claims (e.g., Keycloak realm_access.roles) to Spring authorities
@Bean
public Converter<Jwt, Mono<AbstractAuthenticationToken>> jwtConverter() {
ReactiveJwtAuthenticationConverter converter = new ReactiveJwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
List<String> roles = jwt.getClaimAsStringList("roles");
if (roles == null) return Flux.empty();
return Flux.fromIterable(roles)
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()));
});
return converter;
}
}
Reading the Current User Reactively
Because SecurityContextHolderReactiveSecurityContextHolderContext
// In a WebFlux controller or reactive service
public Mono<String> getCurrentUsername() {
return ReactiveSecurityContextHolder.getContext()
.map(ctx -> ctx.getAuthentication().getName());
}
// Get the full Authentication object
public Mono<UserProfile> getCurrentUserProfile() {
return ReactiveSecurityContextHolder.getContext()
.map(SecurityContext::getAuthentication)
.flatMap(auth -> userService.findByUsername(auth.getName()));
}
// Inject directly in a controller method via @AuthenticationPrincipal
@GetMapping("/profile")
public Mono<UserProfile> getProfile(@AuthenticationPrincipal Mono<UserDetails> principal) {
return principal.flatMap(user -> userService.findByUsername(user.getUsername()));
}
// Read a JWT claim directly
@GetMapping("/me")
public Mono<Map<String, Object>> getMe(@AuthenticationPrincipal Jwt jwt) {
return Mono.just(Map.of(
"subject", jwt.getSubject(),
"email", jwt.getClaimAsString("email"),
"roles", jwt.getClaimAsStringList("roles")
));
}