Contents
- Dependency
- ServerHttpSecurity — Basic Configuration
- ReactiveUserDetailsService
- JWT Authentication WebFilter
- Wiring JWT into SecurityFilterChain
- Reactive Method Security
- OAuth2 Resource Server
- Reading the Current User Reactively
Add the following to your pom.xml. Use spring-boot-starter-webflux rather than spring-boot-starter-web — mixing both on the classpath causes Spring Boot to default to the servlet stack, disabling the reactive security model.
<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>
In a WebFlux application, security is configured via ServerHttpSecurity instead of the servlet-based HttpSecurity. The bean name and return type are different — it returns a SecurityWebFilterChain, not a SecurityFilterChain. The DSL is otherwise very similar to what you already know from servlet Spring Security.
@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 is the reactive counterpart of UserDetailsService. It returns a Mono<UserDetails> so that the user lookup can be performed non-blockingly — for example via R2DBC or a reactive HTTP call to an identity service.
@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());
}
}
A WebFilter in WebFlux is the reactive equivalent of a servlet Filter. The JWT filter intercepts every request, extracts the Bearer token from the Authorization header, validates it, and — if valid — populates the reactive SecurityContext so downstream handlers can access the authenticated principal.
@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
}
}
Register the JWT WebFilter with Spring Security's filter chain by adding it before the SecurityWebFiltersOrder.AUTHENTICATION position. This ensures the JWT context is set before Spring Security's built-in authentication filters run.
@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();
}
}
Method-level security annotations (@PreAuthorize, @PostAuthorize) work with reactive return types when you add @EnableReactiveMethodSecurity to a configuration class. Spring Security evaluates the SpEL expression reactively and only subscribes to the method's Mono/Flux if the check passes.
@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);
}
}
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;
}
}
Because SecurityContextHolder is ThreadLocal-based, it doesn't work in reactive code. Use ReactiveSecurityContextHolder instead — it reads the security context from the Reactor Context that Spring Security populates for each request.
// 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")
));
}