Spring Boot API Gateway
Why Reactive
Spring Cloud Gateway is built on Project Reactor and Netty — it’s a non-blocking, reactive web server. Traditional Spring MVC security (@EnableWebSecurity, HttpSecurity, servlet filters) doesn’t work here. You need the reactive equivalents: @EnableWebFluxSecurity, ServerHttpSecurity, and WebFilter.
This isn’t optional. If you try to use @EnableWebSecurity in a Spring Cloud Gateway application, it will either be ignored or cause a startup error. The reactive security model is fundamentally different from servlet-based security.
The Security Filter Chain
The core of the Gateway’s security lives in SecurityWebFluxConfiguration:
// api-gateway/.../config/SecurityWebFluxConfiguration.java
@Configuration
@EnableWebFluxSecurity
public class SecurityWebFluxConfiguration {
private final CustomReactiveAuthenticationManager customReactiveAuthenticationManager;
private final ServerJwtAuthenticationConverter serverJwtAuthenticationConverter;
@Bean
public SecurityWebFilterChain securityWebFilterChain(
ServerHttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) {
AuthenticationWebFilter authenticationWebFilter =
new AuthenticationWebFilter(customReactiveAuthenticationManager);
authenticationWebFilter.setServerAuthenticationConverter(serverJwtAuthenticationConverter);
http
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/auth/**", "/dashboard", "/actuator").permitAll()
.anyExchange().authenticated()
)
.csrf(ServerHttpSecurity.CsrfSpec::disable)
.securityContextRepository(NoOpServerSecurityContextRepository.getInstance())
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint((exchange, ex) -> Mono.error(ex))
.accessDeniedHandler((exchange, ex) -> Mono.error(ex))
)
.addFilterAt(authenticationWebFilter, AUTHENTICATION);
return http.build();
}
}
Key decisions:
NoOpServerSecurityContextRepository.getInstance()— this makes the security entirely stateless. No server-side session, no session cookie. Every request must carry a JWT. This is critical for horizontal scaling — you can run 10 Gateway replicas without shared session storage.pathMatchers("/auth/**").permitAll()— authentication endpoints are excluded from JWT validation (you need to be able to log in without already being logged in).Mono.error(ex)in exception handlers — instead of writing a response directly, exceptions are propagated to the global exception handler (more on that below).- CSRF disabled — standard for stateless JWT APIs. CSRF protection is for cookie-based sessions.
JWT Token Extraction: The Reactive Way
In servlet-based Spring Security, you’d write a OncePerRequestFilter to extract the JWT. In WebFlux, you implement ServerAuthenticationConverter:
// api-gateway/.../config/ServerJwtAuthenticationConverter.java
@Component
public class ServerJwtAuthenticationConverter implements ServerAuthenticationConverter {
private final JwtService jwtService;
@Override
public Mono<Authentication> convert(ServerWebExchange exchange) {
return Mono.justOrEmpty(
exchange.getRequest().getHeaders().getFirst(HttpHeaders.AUTHORIZATION))
.filter(authHeader -> authHeader.startsWith("Bearer "))
.map(authHeader -> authHeader.substring(7))
.flatMap(jwt -> {
String userEmail = jwtService.extractUsername(jwt);
if (userEmail == null) {
return Mono.empty();
}
return Mono.just(
new UsernamePasswordAuthenticationToken(userEmail, jwt, null));
});
}
}
This is a purely reactive chain: Mono.justOrEmpty() handles the case where there’s no Authorization header (returns empty Mono, meaning no authentication attempt). The JWT string is stored as the credentials field of the UsernamePasswordAuthenticationToken — it’ll be validated in the next step.
Bridging Blocking and Reactive
Here’s the tricky part. The CustomReactiveAuthenticationManager needs to call UserDetailsService.loadUserByUsername(), which is a blocking JPA call. You can’t make blocking calls on a Reactor event loop — it would stall all concurrent requests.
// api-gateway/.../config/CustomReactiveAuthenticationManager.java
@Component
public class CustomReactiveAuthenticationManager implements ReactiveAuthenticationManager {
private final UserDetailsService userDetailsService;
private final JwtService jwtService;
@Override
public Mono<Authentication> authenticate(Authentication authentication) {
String userEmail = authentication.getName();
String jwt = (String) authentication.getCredentials();
return Mono.fromCallable(() -> {
UserDetails userDetails = userDetailsService.loadUserByUsername(userEmail);
if (userDetails != null && jwtService.isTokenValid(jwt, userDetails)) {
return (Authentication) new UsernamePasswordAuthenticationToken(
userDetails, null, userDetails.getAuthorities()
);
} else {
throw new BadCredentialsException("Invalid token or user details.");
}
}).subscribeOn(Schedulers.boundedElastic());
}
}
Mono.fromCallable() wraps the blocking call. .subscribeOn(Schedulers.boundedElastic()) moves the execution to a bounded thread pool designed for blocking I/O — separate from the Netty event loop threads.
The tradeoff: boundedElastic has a limited thread pool (default: 10x CPU cores, capped at a queue of 100,000). Under extreme load, this becomes the bottleneck — every JWT validation requires a thread from this pool to make the JPA call. A fully non-blocking solution would use a reactive database driver (like R2DBC), but that would require rewriting the entire user persistence layer.
RFC 7807 ProblemDetail Error Handling
When authentication fails, the Gateway returns structured error responses following RFC 7807:
// api-gateway/.../exception/ReactiveGlobalExceptionHandler.java
@Component
@Order(-2)
public class ReactiveGlobalExceptionHandler implements WebExceptionHandler {
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
if (exchange.getResponse().isCommitted()) {
return Mono.error(ex);
}
return handleException(ex, request)
.flatMap(response -> response.writeTo(exchange, context));
}
private Mono<ServerResponse> handleException(Throwable ex, ServerRequest request) {
HttpStatus status = getStatus(ex);
String messageCode = getDetailMessageCode(ex);
ProblemDetail problemDetail = ProblemDetail.forStatusAndDetail(status, errorMessage);
problemDetail.setTitle(status.getReasonPhrase());
problemDetail.setProperty("errorCode", messageCode);
return ServerResponse.status(status).bodyValue(problemDetail);
}
private String getDetailMessageCode(Throwable ex) {
if (ex instanceof BadCredentialsException) return "error.auth.bad_credentials";
if (ex instanceof ExpiredJwtException) return "error.auth.token_expired";
if (ex instanceof SignatureException) return "error.auth.invalid_signature";
if (ex instanceof MalformedJwtException) return "error.auth.malformed_token";
if (ex instanceof InsufficientAuthenticationException) return "error.auth.missing_token";
if (ex instanceof DuplicateKeyException) return "error.db.duplicate_key";
return "error.general.internal_error";
}
}
@Order(-2)— this handler must run before Spring’s default error handlers, which have order-1. Without this, Spring’s built-in handler would catch exceptions first and return a generic error page.ProblemDetail— Spring 6’s implementation of RFC 7807. Returns a standardized JSON body withstatus,title,detail, and custom properties likeerrorCode.- Per-exception error codes — the frontend can switch on
errorCodevalues likeerror.auth.token_expiredto show specific UI messages (e.g., “Session expired, please log in again”).
Gateway Routing via Eureka
The Gateway routes are defined in the Config Server’s api-gateway.properties, not hardcoded:
# config-repo/api-gateway.properties
spring.cloud.gateway.routes[0].id=user-service
spring.cloud.gateway.routes[0].uri=lb://user-service
spring.cloud.gateway.routes[0].predicates[0]=Path=/api/v1/users/**
spring.cloud.gateway.routes[1].id=budget-service
spring.cloud.gateway.routes[1].uri=lb://budget-service
spring.cloud.gateway.routes[1].predicates[0]=Path=/v1/api/budgets/**
lb://budget-service resolves through Eureka — the Gateway looks up all instances of budget-service and load-balances across them. Adding a new service requires adding a route in the config repo and restarting the Gateway (or using Spring Cloud Bus for hot reload).
Summary
@EnableWebFluxSecurity— mandatory for Spring Cloud Gateway, traditional MVC security won’t workNoOpServerSecurityContextRepository— stateless JWT, no server-side sessions, scales horizontallySchedulers.boundedElastic()— bridges blocking JPA calls into the reactive pipeline, at the cost of a bounded thread pool@Order(-2)exception handler — must precede Spring’s defaults to catch auth errors- RFC 7807
ProblemDetail— structured error responses with per-exception error codes lb://routing — service discovery via Eureka, load-balanced automatically