
Building an Identity Gateway with Spring Cloud Gateway
A technical guide on implementing an identity gateway using Spring Cloud Gateway with JWT filters and OAuth2 support for microservices.
Spring Cloud Gateway (SCG) operates not as a traditional servlet container but as a stateless, reactive filter pipeline built on Project Reactor. In this architecture, security is enforced by intercepting the ServerWebExchange at the request level, where GlobalFilter implementations are executed in a specific order defined by the @Order annotation or Ordered interface. This guide demonstrates how to construct an identity gateway that validates JWT signatures non-blockingly and proxies authorized requests to microservices. This is Part 3 of the Microservices Security Architecture series.
The Reactive Filter Chain Mechanism
The fundamental mechanism of SCG replaces the standard servlet filter chain with a reactive pipeline. When a request enters the gateway, it is wrapped in a ServerWebExchange. The gateway iterates through a list of GlobalFilter implementations. If a filter decides to block the request, it sets a response on the exchange and returns a Mono<Void>, terminating the chain immediately. If the request passes validation, the filter calls chain.filter(exchange) to pass control to the next filter in the sequence.
This mechanism allows the gateway to inspect, modify, or reject traffic without waiting for I/O operations, which is critical when validating cryptographic signatures found in JSON Web Tokens (JWT). In a scenario where a client named "ClientApp" sends a request to https://api.example.com/orders with a Bearer token in the Authorization header, the SCG instance receives this request. The first mechanism we implement is the extraction of the token string from the header. We do not simply read the header; we create a custom GlobalFilter that inspects the ServerHttpRequest. If the header is missing or malformed, the filter immediately writes a 401 Unauthorized response and terminates the chain. This prevents the request from ever reaching the routing logic or the backend microservice, saving downstream resources.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class JwtAuthenticationFilter implements GlobalFilter {
private final JwtDecoder jwtDecoder;
public JwtAuthenticationFilter(JwtDecoder jwtDecoder) {
this.jwtDecoder = jwtDecoder;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
// Mechanism: Extract header value
String authHeader = request.getHeaders().getFirst("Authorization");
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
return writeErrorResponse(exchange, HttpStatus.UNAUTHORIZED, "Missing or invalid Authorization header");
}
String token = authHeader.substring(7);
// Mechanism: Validate signature non-blocking
return jwtDecoder.decode(token)
.flatMap(jwt -> ReactiveSecurityContextHolder.withContext(SecurityContextFactory.create(jwt))
.then(chain.filter(exchange)))
.onErrorResume(AuthenticationException.class, ex ->
writeErrorResponse(exchange, HttpStatus.UNAUTHORIZED, "Invalid token"));
}
private Mono<Void> writeErrorResponse(ServerWebExchange exchange, HttpStatus status, String body) {
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(status);
DataBufferFactory bufferFactory = response.bufferFactory();
DataBuffer buffer = bufferFactory.wrap(body.getBytes());
return response.writeWith(Mono.just(buffer));
}
}Non-Blocking Token Validation Strategy
The code above illustrates the mechanism of using jwtDecoder.decode() directly, which returns a Mono<Jwt>. In a reactive environment, you cannot block the main event loop thread while performing the heavy lifting of cryptographic verification, such as RSA signature verification. By utilizing the reactive Mono stream provided by the decoder, we keep the Netty event loop free to handle other connections while the CPU-intensive task completes asynchronously.
Once the JwtDecoder (available since Spring Security 5.0, with reactive support in 5.7+) successfully decodes the token and verifies the signature against the configured public key, the resulting Jwt object is wrapped in a JwtAuthenticationToken. This token is then attached to the SecurityContext of the ServerWebExchange via ReactiveSecurityContextHolder. This mechanism is crucial because downstream microservices often rely on the SecurityContext to determine user permissions. However, SCG does not automatically forward the SecurityContext to the backend unless configured to do so. In a typical microservices architecture, the gateway acts as a trusted proxy. The backend service expects to see the user identity in the request headers or as a separate authentication context. By attaching the Authentication object to the exchange, we ensure that if the downstream service uses Spring Security's @PreAuthorize or @Secured annotations, the principal is available within the reactive context of the downstream application, provided the downstream application is also configured as an OAuth2 Resource Server or explicitly reads the SecurityContext from the ServerWebExchange context.
OAuth2 Resource Server Integration
The next layer of the mechanism involves integrating with OAuth2 protocols directly. Instead of manually implementing the JwtDecoder logic, we can leverage Spring Security's NimbusReactiveJwtDecoder for JWT validation. When SCG is configured as an OAuth2 Resource Server, it expects the token to be issued by a specific authorization server, such as Keycloak or Auth0. The configuration defines the issuer URI and the JWK Set URI. The gateway fetches the public keys from the JWK Set URI and caches them. When a token arrives, the gateway validates the kid (key ID) in the token header, retrieves the corresponding public key, and verifies the signature.
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com
jwk-set-uri: https://auth.example.com/realms/myrealm/protocol/openid-connect/certsThis configuration tells the gateway to trust tokens signed by the private key corresponding to the public keys hosted at the JWK URI. The mechanism here is a "trust boundary." The gateway assumes the authorization server is the source of truth. If the token's signature does not match any key in the fetched JWK set, the JwtDecoder throws an InvalidTokenException, which our filter catches to return a 401. This prevents the gateway from routing requests with expired, revoked, or tampered tokens to the internal services.
It is important to note a tradeoff here: relying on the SecurityContext propagation across services requires that both the gateway and the downstream services share the same security configuration strategy. If the downstream service expects a standard Servlet Authentication object but receives a reactive one, or if the token claims are not mapped correctly, the downstream authorization logic will fail. A common pattern is to have the gateway extract the sub (subject) claim and add it to a custom header, such as X-User-Id, for services that do not have Spring Security fully integrated. This decouples the identity verification mechanism in the gateway from the authorization logic in the services.
Refresh Token Handling and Caching
Another critical mechanism is the handling of refresh tokens. While access tokens (short-lived) are validated by the gateway, refresh tokens (long-lived) are typically not passed to the backend services. The gateway should validate the access token's expiration. If the token is expired, the gateway returns a 401. It is generally the client's responsibility to detect this 401 and initiate a refresh flow with the authorization server, rather than the gateway handling the refresh logic itself. This keeps the gateway stateless and focused on its primary role: routing and validating.
Finally, consider the performance implications of the JWK Set fetching. If the authorization server is slow to respond or the network is unstable, fetching the JWK set on every request would cause a bottleneck. The mechanism of caching is essential here. Spring Security's NimbusReactiveJwtDecoder caches the keys with a TTL (Time To Live) based on the exp (expiration) claim of the keys themselves. This ensures that the gateway does not block waiting for the key server unless the keys have actually rotated. This caching mechanism is transparent to the developer but vital for maintaining the non-blocking nature of the gateway under high load.
Common Pitfalls
When implementing an identity gateway, several pitfalls can compromise the reactive architecture:
- Blocking the Event Loop: Using
Mono.fromCallablewithSchedulers.boundedElastic()wraps the decoding logic in a blocking call, effectively halting the Netty event loop. Always use the native reactiveMonoreturned by theJwtDecoder. - Assuming Automatic Context Propagation: The
SecurityContextdoes not magically propagate to downstream services. Downstream services must be explicitly configured to read the context or receive the token via headers. - Ignoring JWK Cache Expiration: Relying on default caching without considering key rotation can lead to validation failures or security gaps. Ensure your
JwtDecoderconfiguration respects theexpclaim for cache invalidation.
Practical Takeaways
Adopt these mental models to maintain a secure and efficient gateway:
- Reactive Decoding: Use
Monodirectly for token validation; never wrap reactive calls inCallable. - Context Propagation: Downstream services must be configured to read the
SecurityContextor expect specific headers to access the principal. - Trust Boundaries: The gateway validates identity (authentication), while downstream services handle authorization logic.
FAQ
Can I use Schedulers.boundedElastic() for JWT validation?
No. Using Schedulers.boundedElastic() blocks the Netty event loop during the decode operation. Spring Security's JwtDecoder already returns a Mono that handles the decoding non-blockingly.
How do I handle refresh tokens? The gateway should not handle refresh logic. If an access token is expired, the gateway returns a 401. The client should then use its refresh token to obtain a new access token from the authorization server.
Does Spring Security 5.7 change the API?
Spring Security 5.7 introduced significant improvements to reactive support, particularly for OAuth2 Resource Servers, but the core JwtDecoder interface remains consistent. Reactive support was available in earlier versions, but 5.7 refined the implementation details.
Conclusion
Building an identity gateway with Spring Cloud Gateway requires understanding that security is not an afterthought but a core part of the reactive filter chain. By placing the JWT validation logic in a high-priority GlobalFilter, leveraging the OAuth2 Resource Server auto-configuration, and ensuring downstream services are properly configured, you create an effective entry point that protects your microservices. The mechanism ensures that only requests with valid, unexpired, and correctly signed tokens are allowed to traverse the network, effectively acting as a gatekeeper that enforces the trust boundary between the public internet and your internal microservices architecture.
Related posts
Keycloak Architecture Deep Dive: Internal Components and Data Flow
An examination of Keycloak architecture, internal components, SPI extensions, themes, and the core data model for advanced developers.
SAML in Microservices: Patterns and Anti-Patterns
An examination of SAML integration patterns and anti-patterns within microservices architectures, covering API gateway strategies and token translation.
Building Multi-Factor Authentication with TOTP in Spring Boot
A technical walkthrough for implementing Two-Factor Authentication using TOTP and Google Authenticator within a Spring Boot application.