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:

ServletReactive
SecurityFilterChainSecurityWebFilterChain
HttpSecurityServerHttpSecurity
SecurityContextHolderReactiveSecurityContextHolder
UserDetailsServiceReactiveUserDetailsService
AuthenticationManagerReactiveAuthenticationManager
@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

ConcernServletReactive
Config annotation@EnableWebSecurity@EnableWebFluxSecurity
Filter chain typeSecurityFilterChainSecurityWebFilterChain
Security contextSecurityContextHolder (ThreadLocal)ReactiveSecurityContextHolder (subscriber context)
User details serviceUserDetailsServiceReactiveUserDetailsService
Authentication managerAuthenticationManagerReactiveAuthenticationManager
Method security@EnableMethodSecurity@EnableReactiveMethodSecurity
Test supportmockMvc.perform(...)webTestClient.mutateWith(...)

Everything else — authorization rules, JWT configuration, CORS, CSRF — maps directly with a reactive equivalent.


Key Takeaways

  • Reactive security uses @EnableWebFluxSecurity and ServerHttpSecurity — not @EnableWebSecurity and HttpSecurity
  • ReactiveSecurityContextHolder stores security context in the subscriber context, not a thread-local
  • ReactiveUserDetailsService and ReactiveAuthenticationManager return Mono<UserDetails> and Mono<Authentication>
  • JWT resource server configuration is identical to the servlet version; add it with .oauth2ResourceServer(oauth2 -> oauth2.jwt(...))
  • @EnableReactiveMethodSecurity enables @PreAuthorize on Mono/Flux-returning methods
  • Test with WebTestClient.mutateWith(mockJwt()) — the reactive equivalent of MockMvc’s jwt() post-processor

Next: Spring Security Best Practices and Production Checklist — the final article, consolidating everything into an actionable reference.