Skip to content
Ashish.
All posts
Diagram illustrating Keycloak and Spring Boot integration patterns for multi-tenancy and reactive security.

Keycloak and Spring Boot Integration: Advanced Patterns

Explore advanced Keycloak and Spring Boot integration patterns including multi-tenant setups and reactive security.

By Ashish SrivastavaPart 4 of Keycloak Masterclass Series

Moving beyond the default spring-boot-starter-oauth2-client configuration, advanced integration requires manual control over realm resolution for multi-tenancy and switching to ReactiveSecurityContext to prevent blocking I/O during token validation in high-throughput microservices. This approach shifts the architecture from a static, synchronous model to a dynamic, non-blocking one, ensuring scalability and resilience.

The Cost of Blocking: Reactive Security Contexts

When you integrate Keycloak with Spring Boot using the standard spring-boot-starter-oauth2-client, the framework assumes a synchronous, web-application context. It creates a SecurityFilterChain that intercepts requests, extracts the token, and often triggers a blocking call to the Keycloak userinfo endpoint or the public key endpoint to validate the token's signature and claims. In a reactive architecture using Project Reactor or Spring WebFlux, this blocking behavior defeats the purpose of non-blocking I/O. If the Keycloak server is under load or network latency spikes, the entire event loop stalls.

The mechanism here is simple: the default JwtDecoder implementation in Spring Security often relies on a RestTemplate (blocking) or a WebClient that isn't properly configured for the specific non-blocking context of a reactive application. To fix this, you must explicitly configure a ReactiveJwtDecoder. This decoder uses a local JwksResource cache that fetches the public keys from Keycloak's .well-known/openid-configuration endpoint only once on startup or upon a specific refresh trigger, storing them in memory. Subsequent token validations happen entirely within the JVM, comparing the token's kid (Key ID) against the cached JWK Set.

Consider an actor named AuthService running in a reactive environment. When a request arrives with a JWT, AuthService does not call out to Keycloak. Instead, it uses the NimbusReactiveJwtDecoder. This decoder extracts the alg and kid from the token header, looks up the corresponding RSA public key from the local cache, and verifies the signature using the JWK key fetched from the cache. If the signature matches, the claims are mapped to a ReactiveAuthentication object. This entire flow is non-blocking because the heavy lifting of crypto verification happens on the thread, while the network I/O for key fetching is decoupled and handled asynchronously.

Multi-Tenancy: Dynamic Realm Resolution

Standard configurations hardcode the realm-name in application.properties. This works for a single-tenant application but collapses in a multi-tenant SaaS environment where each customer belongs to a different Keycloak realm. If you have three tenants—acme, globex, and initech—hardcoding one realm name means the application can only authenticate users from that specific realm.

The mechanism for solving this involves intercepting the request before the security filter chain attempts to resolve the realm. You need a custom RealmResolver or a filter that inspects the incoming request headers or the subdomain. For example, if a request comes to acme.myapp.com, the system must map acme to the Keycloak realm named acme-realm.

In a reactive setup, you cannot simply switch the ClientRegistration at runtime because Spring Security builds the ClientRegistration bean graph at startup. Instead, you must implement a custom ServerOAuth2AuthorizedClientProvider or maintain a map of pre-configured ClientRegistrations keyed by the resolved realm. When a request arrives, the resolver extracts the tenant identifier and selects the appropriate pre-configured registration from the map.

Imagine a request arriving at globex.myapp.com/login. A TenantHeaderResolver extracts globex from the Host header. It then queries a configuration service (or a database) to find the Keycloak issuer URL for globex. Crucially, this lookup must use reactive data sources (e.g., R2DBC or a reactive REST client) to maintain the non-blocking architecture; a synchronous database call would stall the event loop. The resolver then selects the pre-defined ClientRegistration for globex from the internal map and passes it to the ReactiveOAuth2AuthorizedClientManager for the current request context. This ensures that the subsequent token exchange and validation use the correct Keycloak endpoint and public key set for the specific tenant. Without this dynamic resolution, the application would attempt to validate a globex token against the acme realm's public keys, resulting in a signature mismatch error.

// Conceptual example of selecting a pre-configured registration
ClientRegistration selectedRegistration = realmToRegistrationMap.get(tenantId);
ReactiveOAuth2AuthorizedClientManager manager = new ReactiveOAuth2AuthorizedClientManager(selectedRegistration);

Service-to-Service Propagation: The Resource Server Pattern

In a microservices architecture, the API Gateway authenticates the end-user and obtains a JWT. However, the Gateway must forward this request to downstream services (e.g., OrderService, InventoryService). These downstream services do not act as OAuth2 Clients (which implies they initiate a login flow); they act as OAuth2 Resource Servers (which protect endpoints).

The mechanism here is token propagation. The Gateway extracts the Authorization: Bearer <token> header and passes it unchanged to the downstream service. The downstream service must be configured as a Resource Server, not a Client. If OrderService is misconfigured as a Client, it will try to redirect the user to Keycloak for a login, breaking the flow for internal service calls.

For OrderService, the configuration shifts to spring-boot-starter-oauth2-resource-server. It is critical to distinguish this starter from spring-boot-starter-oauth2-client, which is intended for the client flow (initiating authentication). The OrderService must trust the issuer from the Gateway. If the Gateway and OrderService are in the same Keycloak realm, the issuer-uri points to https://keycloak.example.com/realms/my-realm.

However, a common pitfall occurs when the Gateway acts as a "BFF" (Backend for Frontend) and issues its own access token, or when the Gateway strips the original token. The correct pattern is "Pass-through". The Gateway validates the token, adds necessary context (like user ID) to the headers, and forwards the original token.

If you are using Spring Boot 3.x with Spring Security 6, the configuration for OrderService looks like this:

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://keycloak.example.com/realms/my-realm
          jwk-set-uri: https://keycloak.example.com/realms/my-realm/protocol/openid-connect/certs

This tells OrderService to validate the incoming JWT against the Keycloak public key set. It does not ask Keycloak for a new token. It simply verifies the signature. If the token was issued by Keycloak and signed with the private key of the realm, and the OrderService has the correct public key (fetched from the JWK Set URI), the validation succeeds.

This pattern scales because it removes the need for OrderService to maintain session state or perform network calls to Keycloak for every request, provided the JWK cache is refreshed correctly. The ReactiveJwtDecoder mentioned earlier is the engine that makes this efficient, ensuring that the cryptographic verification is fast and non-blocking.

Opinion: The Trade-off of Local Validation

It is tempting to rely on introspection endpoints (/protocol/openid-connect/introspect) to validate tokens. This allows Keycloak to check if a token is revoked or expired in real-time. However, calling the introspection endpoint for every request introduces significant latency and a single point of failure. If Keycloak goes down, your entire application stack halts.

I recommend local validation (JWK-based) for the vast majority of cases. The trade-off is that revocation is delayed by the JWK refresh interval (usually 5-10 minutes). In most high-performance systems, a 5-minute window for revocation is an acceptable risk compared to the latency and availability risks of synchronous introspection. If you require immediate revocation, you must implement a short-lived access token strategy (e.g., 5-minute tokens) and a refresh token rotation scheme, rather than relying on the introspection endpoint.

Common Pitfalls

When implementing advanced Keycloak patterns, several recurring issues can compromise system stability:

  1. Blocking I/O in Reactive Apps: Using a standard RestTemplate or synchronous database lookups inside a reactive filter chain will freeze the event loop, negating the benefits of WebFlux. Always ensure data access layers (R2DBC, Reactor Netty) are used.
  2. Incorrect Realm Resolution: Attempting to construct ClientRegistration objects dynamically at runtime without a pre-loaded map often leads to race conditions or bean initialization errors. Pre-registering realm-specific registrations is the safer approach.
  3. Token Propagation Breakage: Forgetting to configure downstream services as Resource Servers causes them to attempt client-side login flows for internal calls, creating infinite redirect loops or authentication failures.

Practical Takeaways

To successfully navigate these integrations, adopt these mental models:

  • Cache Locally: Treat the JWK set as a local resource. Fetch it once and reuse it to avoid network latency on every request.
  • Immutable Registrations: View ClientRegistration as a compile-time or startup-time artifact. Use maps or providers to select them, not to build them per request.
  • Stateless Downstream: Remember that downstream microservices should never hold session state or initiate authentication flows; they are purely consumers of validated tokens.

FAQ

Q: Can I use spring-boot-starter-oauth2-client for downstream services? A: No. Downstream services should use spring-boot-starter-oauth2-resource-server. The client starter is designed for initiating authentication flows (like logging in a user), whereas the resource server starter is designed for validating incoming tokens.

Q: How do I handle immediate token revocation in a reactive setup? A: Local JWK validation inherently delays revocation detection. For immediate revocation, you must implement a very short access token TTL (e.g., 1-2 minutes) combined with a refresh token rotation strategy, or accept the latency of a reactive introspection call (which sacrifices some performance).

Q: Is it possible to support multi-tenancy without pre-configuring realms? A: While theoretically possible by fetching configuration on the fly, it is highly discouraged in reactive systems due to the latency and complexity of managing dynamic ClientRegistration objects. Pre-configuring realms in a map or using a dedicated configuration service accessed via reactive clients is the recommended pattern.

Conclusion

Advanced integration of Keycloak and Spring Boot is less about configuration files and more about understanding the flow of trust. You move from a blocking, hardcoded model to a reactive, dynamic one. By caching public keys locally, you eliminate network latency. By resolving realms dynamically, you support multi-tenancy without code changes. By configuring downstream services as Resource Servers, you ensure secure, stateless communication between microservices. Each of these mechanisms addresses a specific failure mode in the default setup, providing a solid foundation for modern, scalable applications.

Related posts