Spring Security reactivo: WebFlux y ReactiveAuthenticationManager

Avanzado
Spring Security
Spring Security
Actualizado: 19/04/2026

Spring WebFlux es la pila reactiva no bloqueante de Spring. Cuando se combina con Spring Security, todos los conceptos del modelo servlet (filtros, AuthenticationManager, UserDetailsService) tienen su contraparte reactiva que opera con Mono y Flux.

La razón para usar WebFlux suele ser alta concurrencia con pocas threads: gateways, proxies, agregadores de APIs. En estos escenarios, la versión bloqueante de Spring Security saturaría el pool de hilos rápidamente.

Diferencias clave con el modelo servlet

| Concepto servlet | Equivalente reactivo | |---|---| | SecurityFilterChain | SecurityWebFilterChain | | AuthenticationManager | ReactiveAuthenticationManager | | UserDetailsService | ReactiveUserDetailsService | | SecurityContextHolder | ReactiveSecurityContextHolder | | OncePerRequestFilter | WebFilter | | HttpSecurity | ServerHttpSecurity |

La regla mnemotécnica: Reactive para componentes y Server para configuración HTTP.

Dependencias

<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>

Si spring-boot-starter-web también está en el classpath, Spring Boot arranca con servlet por defecto. Quítalo o configura spring.main.web-application-type=reactive.

SecurityWebFilterChain mínimo

@Configuration
@EnableWebFluxSecurity
public class WebFluxSecurityConfig {

    @Bean
    SecurityWebFilterChain http(ServerHttpSecurity http) {
        http
            .authorizeExchange(exchange -> exchange
                .pathMatchers("/public/**").permitAll()
                .pathMatchers("/admin/**").hasRole("ADMIN")
                .anyExchange().authenticated())
            .httpBasic(Customizer.withDefaults())
            .formLogin(Customizer.withDefaults())
            .csrf(ServerHttpSecurity.CsrfSpec::disable);
        return http.build();
    }
}

pathMatchers reemplaza a requestMatchers, authorizeExchange a authorizeHttpRequests y ServerHttpSecurity a HttpSecurity. La estructura es idéntica, solo cambian los nombres.

ReactiveUserDetailsService

@Bean
ReactiveUserDetailsService users(PasswordEncoder encoder, UsuarioRepository repo) {
    return username -> repo.findByUsername(username)
        .map(u -> User.withUsername(u.getUsername())
            .password(u.getPassword())
            .roles(u.getRol())
            .build());
}

UsuarioRepository es un ReactiveCrudRepository o un R2dbcRepository. La cadena reactiva no bloquea.

No mezcles JDBC con WebFlux. Si tu repositorio es bloqueante, las ventajas de WebFlux desaparecen y suelen aparecer deadlocks sutiles. Usa R2DBC o llama a JDBC sobre Schedulers.boundedElastic().

Acceder al SecurityContext en un controlador reactivo

ReactiveSecurityContextHolder.getContext() devuelve un Mono<SecurityContext>. Por convención, se compone con el resto de la cadena.

@GetMapping("/me")
public Mono<Map<String, Object>> me() {
    return ReactiveSecurityContextHolder.getContext()
        .map(SecurityContext::getAuthentication)
        .map(auth -> Map.of(
            "username", auth.getName(),
            "authorities", auth.getAuthorities()
        ));
}

Para inyectar directamente la Authentication como parámetro, usa @AuthenticationPrincipal:

@GetMapping("/perfil")
public Mono<Perfil> perfil(@AuthenticationPrincipal UserDetails user) {
    return service.cargarPerfil(user.getUsername());
}

ReactiveAuthenticationManager personalizado

Para flujos de autenticación a medida (claves API, JWT custom), implementamos un ReactiveAuthenticationManager.

@Component
public class ApiKeyAuthenticationManager implements ReactiveAuthenticationManager {

    private final ReactiveApiKeyRepository repo;

    @Override
    public Mono<Authentication> authenticate(Authentication authentication) {
        String key = (String) authentication.getCredentials();
        return repo.findByKey(key)
            .switchIfEmpty(Mono.error(new BadCredentialsException("API key invalida")))
            .map(apiKey -> new UsernamePasswordAuthenticationToken(
                apiKey.getOwner(),
                key,
                List.of(new SimpleGrantedAuthority("ROLE_API"))));
    }
}

Y un WebFilter que extrae el header y delega:

@Component
public class ApiKeyWebFilter implements WebFilter {

    private final ApiKeyAuthenticationManager authManager;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {
        String key = exchange.getRequest().getHeaders().getFirst("X-Api-Key");
        if (key == null) return chain.filter(exchange);
        Authentication auth = new UsernamePasswordAuthenticationToken(null, key);
        return authManager.authenticate(auth)
            .flatMap(authenticated -> chain.filter(exchange)
                .contextWrite(ReactiveSecurityContextHolder.withAuthentication(authenticated)));
    }
}

El truco está en contextWrite. La cadena reactiva propaga el SecurityContext por Reactor Context, no por ThreadLocal.

Resource Server reactivo con JWT

La configuración con OAuth2 JWT es directa.

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: "https://auth.demo.local/realms/empresa"
@Bean
SecurityWebFilterChain api(ServerHttpSecurity http) {
    http
        .securityMatcher(ServerWebExchangeMatchers.pathMatchers("/api/**"))
        .authorizeExchange(auth -> auth.anyExchange().authenticated())
        .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()));
    return http.build();
}

Spring crea automáticamente un ReactiveJwtDecoder que descarga el JWK Set del issuer y valida cada token entrante.

Method security reactivo

Para @PreAuthorize en métodos que devuelven Mono o Flux, activa method security reactivo.

@Configuration
@EnableReactiveMethodSecurity
public class MethodSec { }
@Service
public class TareaService {

    @PreAuthorize("hasRole('USER')")
    public Mono<Tarea> obtener(String id) {
        return repo.findById(id);
    }

    @PreAuthorize("@permisos.puedeEditar(authentication, #id)")
    public Mono<Tarea> editar(String id, ActualizarCmd cmd) {
        return repo.findById(id).flatMap(t -> repo.save(t.actualizar(cmd)));
    }
}

@permisos.puedeEditar debe devolver Mono<Boolean> para encajar en la cadena reactiva.

CORS reactivo

@Bean
CorsWebFilter corsFilter() {
    CorsConfiguration config = new CorsConfiguration();
    config.setAllowedOrigins(List.of("https://app.demo.local"));
    config.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
    config.setAllowCredentials(true);
    UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
    source.registerCorsConfiguration("/api/**", config);
    return new CorsWebFilter(source);
}

Logout reactivo

.logout(logout -> logout
    .logoutUrl("/auth/logout")
    .logoutHandler(new HeaderWriterServerLogoutHandler(new ClearSiteDataServerHttpHeadersWriter(
        ClearSiteDataServerHttpHeadersWriter.Directive.COOKIES,
        ClearSiteDataServerHttpHeadersWriter.Directive.STORAGE
    ))))

Clear-Site-Data es una cabecera moderna que ordena al navegador eliminar cookies, almacenamiento y caché vinculados al dominio.

Testing reactivo con WebTestClient

@SpringBootTest
@AutoConfigureWebTestClient
class TareaApiTest {

    @Autowired WebTestClient client;

    @Test
    @WithMockUser(username = "ana", roles = "USER")
    void usuarioPuedeListarTareas() {
        client.get().uri("/api/tareas")
            .exchange()
            .expectStatus().isOk();
    }

    @Test
    @WithMockUser(roles = "USER")
    void userNoAccedeAdmin() {
        client.get().uri("/api/admin/usuarios")
            .exchange()
            .expectStatus().isForbidden();
    }
}

mutateWith(SecurityMockServerConfigurers.mockJwt()) permite simular un JWT con claims personalizados sin levantar el authorization server.

client.mutateWith(mockJwt().jwt(jwt -> jwt.claim("scope", "tareas:read")))
    .get().uri("/api/tareas")
    .exchange().expectStatus().isOk();

Errores frecuentes

  • Bloquear dentro de Mono.map: rompe el modelo reactivo. Usa siempre operadores no bloqueantes (flatMap con Mono.fromCallable sobre Schedulers.boundedElastic para JDBC).
  • Olvidar contextWrite: si autenticas en un WebFilter pero no propagas el contexto, el siguiente filtro o controlador no verá la autenticación.
  • Mezclar SecurityContextHolder con WebFlux: el ThreadLocal no funciona aquí. Usa ReactiveSecurityContextHolder.
  • Usar formLogin con SPA: en una API stateless reactiva, formLogin no tiene sentido. Mantén solo httpBasic o JWT.
Alan Sastre - Autor del tutorial

Alan Sastre

Ingeniero de Software y formador, CEO en CertiDevs

Ingeniero de software especializado en Full Stack y en Inteligencia Artificial. Como CEO de CertiDevs, Spring Security es una de sus áreas de expertise. Con más de 15 años programando, 6K seguidores en LinkedIn y experiencia como formador, Alan se dedica a crear contenido educativo de calidad para desarrolladores de todos los niveles.

Más tutoriales de Spring Security

Explora más contenido relacionado con Spring Security y continúa aprendiendo con nuestros tutoriales gratuitos.

Aprendizajes de esta lección

Configurar Spring Security sobre WebFlux con ReactiveAuthenticationManager, ServerSecurityContextRepository, ReactiveUserDetailsService y mapear principal con ReactiveSecurityContextHolder.