Token Relay Pattern
Token Relay Pattern
The mechanism by which a BFF or API Gateway forwards a user's OAuth2 access token as a
Bearerheader to downstream services, so that each service can independently verify identity and enforce authorization — without the client needing to manage or send tokens directly.
Core Idea
After the BFF acquires an access_token via the OAuth2-BFF-Pattern, downstream microservices also need to know who the user is and what they are allowed to do. The Token Relay Pattern is the bridge: the BFF extracts the token from its server-side session and attaches it as an Authorization: Bearer <token> header to every outbound request to downstream services.
From the downstream service's perspective, every incoming request carries a standard Bearer token that it can validate against the IdP's public keys (JWKS endpoint). The downstream service never needs to know about the BFF's session cookie — it speaks pure OAuth2/JWT.
Key Principles
- BFF is the only party that reads from the session — downstream services receive standard Bearer tokens, not session cookies
- Token relay is transparent to Angular — the Angular app makes ordinary HTTP calls; Bearer token injection happens in the BFF layer
- Spring Cloud Gateway has
TokenRelayas a built-in filter — one line of configuration activates it for a route - For WebClient calls (custom aggregation), use
ServerOAuth2AuthorizedClientExchangeFilterFunction— this handles extraction + injection programmatically - Silent refresh is part of token relay — if the access token is expired, the BFF refreshes it before relaying
How It Works
Option A: Spring Cloud Gateway TokenRelay Filter (Recommended for proxy routes)
The TokenRelayGatewayFilterFactory is a built-in Spring Cloud Gateway filter. It reads the OAuth2 access token from the current ServerWebExchange's security context (which Spring Security populates from the session), then adds it as an Authorization: Bearer header to the proxied request.
Configuration in application.yml:
spring:
cloud:
gateway:
routes:
- id: user-service
uri: lb://user-service
predicates:
- Path=/api/users/**
filters:
- StripPrefix=1
- TokenRelay= # ← this single filter handles token relay
- name: CircuitBreaker
args:
name: userServiceCB
fallbackUri: forward:/fallback/users
- id: order-service
uri: lb://order-service
predicates:
- Path=/api/orders/**
filters:
- StripPrefix=1
- TokenRelay= # ← repeat for each route that needs auth
- id: product-service
uri: lb://product-service
predicates:
- Path=/api/products/**
filters:
- StripPrefix=1
- TokenRelay=Important: The TokenRelayGatewayFilterFactory is available without a @Bean declaration when spring-boot-starter-oauth2-client is on the classpath. Spring Boot auto-configures it.
The SaveSession filter is required alongside TokenRelay:
filters:
- SaveSession # flush session BEFORE forwarding (ensures token is persisted)
- TokenRelay=Without SaveSession, a race condition can occur where a new session is not yet flushed to Redis when the request is forwarded.
Option B: WebClient with ServerOAuth2AuthorizedClientExchangeFilterFunction (for custom aggregation)
When a BFF route is not a simple proxy but invokes downstream services programmatically (e.g., in a @RestController doing Mono.zip aggregation), use the ServerOAuth2AuthorizedClientExchangeFilterFunction:
package com.example.bff.client;
import org.springframework.security.oauth2.client.ReactiveOAuth2AuthorizedClientManager;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.stereotype.Component;
import org.springframework.web.reactive.function.client.WebClient;
/**
* WebClient configured with OAuth2 token relay filter.
* The filter extracts the current user's access token from the reactive security context
* and adds it as Authorization: Bearer on every outbound request.
*/
@Component
public class DownstreamWebClientConfig {
/**
* The ReactiveOAuth2AuthorizedClientManager manages token retrieval and refresh.
* Spring Boot auto-configures this bean when oauth2-client is on the classpath.
*/
@Bean
public WebClient downstreamWebClient(
ReactiveOAuth2AuthorizedClientManager authorizedClientManager,
WebClient.Builder builder) {
ServerOAuth2AuthorizedClientExchangeFilterFunction oauth2Filter =
new ServerOAuth2AuthorizedClientExchangeFilterFunction(authorizedClientManager);
// Optionally set the default client registration to use
oauth2Filter.setDefaultClientRegistrationId("keycloak");
return builder
.filter(oauth2Filter)
.build();
}
}Usage in a controller or service:
package com.example.bff.aggregation;
import org.springframework.security.oauth2.client.web.reactive.function.client.ServerOAuth2AuthorizedClientExchangeFilterFunction;
import org.springframework.stereotype.Service;
import org.springframework.web.reactive.function.client.WebClient;
import reactor.core.publisher.Mono;
import static org.springframework.security.oauth2.client.web.reactive.function.client
.ServerOAuth2AuthorizedClientExchangeFilterFunction.clientRegistrationId;
@Service
public class DashboardService {
private final WebClient downstreamWebClient;
public DashboardService(WebClient downstreamWebClient) {
this.downstreamWebClient = downstreamWebClient;
}
public Mono<UserProfile> getUserProfile(String userId) {
return downstreamWebClient.get()
.uri("http://user-service/users/{id}", userId)
// The filter automatically injects Bearer token from the reactive security context.
// No manual token extraction needed.
.retrieve()
.bodyToMono(UserProfile.class);
}
public Mono<DashboardResponse> getDashboard(String userId) {
// Both calls relay the token automatically; no explicit token handling
Mono<UserProfile> profileMono = getUserProfile(userId)
.onErrorResume(ex -> Mono.just(UserProfile.empty(userId)));
Mono<OrderSummary> ordersMono = getOrderSummary(userId)
.onErrorResume(ex -> Mono.just(OrderSummary.empty()));
return Mono.zip(profileMono, ordersMono)
.map(tuple -> new DashboardResponse(tuple.getT1(), tuple.getT2()));
}
private Mono<OrderSummary> getOrderSummary(String userId) {
return downstreamWebClient.get()
.uri("http://order-service/orders?userId={id}&limit=10", userId)
.retrieve()
.bodyToMono(OrderSummary.class);
}
public record UserProfile(String id, String name) {
public static UserProfile empty(String id) { return new UserProfile(id, "Unknown"); }
}
public record OrderSummary(int count, double total) {
public static OrderSummary empty() { return new OrderSummary(0, 0.0); }
}
public record DashboardResponse(UserProfile user, OrderSummary orders) {}
}Token Refresh During Relay
When the access_token stored in the session has expired, the ReactiveOAuth2AuthorizedClientManager handles the refresh transparently:
- Filter checks
OAuth2AuthorizedClient.getAccessToken().getExpiresAt() - If expired, calls the IdP
/tokenendpoint withgrant_type=refresh_token - Updates the
OAuth2AuthorizedClientinServerOAuth2AuthorizedClientRepository(which persists to Redis) - Injects the new
access_tokeninto the outbound request - The downstream service receives a valid token; the operation continues without error
Angular sees none of this — from its perspective, the API call simply succeeded.
Required for refresh: the offline_access scope must be included in the client registration, and the IdP must return a refresh_token.
Configuration for ReactiveOAuth2AuthorizedClientManager Bean
Spring Boot auto-configures this, but for custom behaviour (e.g., token refresh strategy), declare it explicitly:
@Bean
public ReactiveOAuth2AuthorizedClientManager authorizedClientManager(
ReactiveClientRegistrationRepository clientRegistrationRepository,
ServerOAuth2AuthorizedClientRepository authorizedClientRepository) {
ReactiveOAuth2AuthorizedClientProvider authorizedClientProvider =
ReactiveOAuth2AuthorizedClientProviderBuilder.builder()
.authorizationCode()
.refreshToken() // enable silent refresh
.clientCredentials()
.build();
DefaultReactiveOAuth2AuthorizedClientManager manager =
new DefaultReactiveOAuth2AuthorizedClientManager(
clientRegistrationRepository, authorizedClientRepository);
manager.setAuthorizedClientProvider(authorizedClientProvider);
return manager;
}Downstream Service Configuration
Each downstream service receiving a relayed token must be configured as an OAuth2 Resource Server:
# In the downstream service's application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://idp.example.com
# Spring Security validates the JWT signature using the IdP's JWKS endpoint
# (auto-discovered from issuer-uri + /.well-known/openid-configuration)// Downstream service Security config
@Configuration
@EnableWebFluxSecurity // or @EnableWebSecurity for Servlet
public class ResourceServerConfig {
@Bean
public SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http) {
return http
.authorizeExchange(exchanges -> exchanges
.pathMatchers("/actuator/health").permitAll()
.anyExchange().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(Customizer.withDefaults()) // validates Bearer JWT
)
.csrf(csrf -> csrf.disable()) // service-to-service, no browser cookies
.build();
}
}Common Misconceptions
"TokenRelay= requires a custom bean"— Spring Boot auto-configuresTokenRelayGatewayFilterFactorywhenspring-boot-starter-oauth2-clientis present. No@Beanneeded for standard usage."I must manually extract the token from the security context"— Spring's filter functions handle this. Manual extraction is fragile and should be avoided."TokenRelay works without SaveSession"— In a clustered deployment with Redis session,SaveSessionmust precedeTokenRelayto guarantee the token is persisted before the route forwards the request."The downstream service needs access to the BFF session"— No. The downstream service receives a standard Bearer JWT and validates it independently against the IdP's JWKS endpoint.
Why It Matters
Token Relay is the link between the BFF's session-based security model and the downstream services' JWT-based security model. Without it, the BFF would be an authentication dead-end — it would verify the user but have no way to propagate identity to the services that fulfil the request.
Combined with OAuth2-BFF-Pattern for token acquisition and Spring-Cloud-Gateway for proxy routing, Token Relay completes the security chain from browser to downstream service.
Related Concepts
| Concept | Relationship |
|---|---|
| OAuth2-BFF-Pattern | Token acquisition; Token Relay is the distribution phase |
| BFF-For-SPA | Full end-to-end flow that uses Token Relay |
| Spring-Cloud-Gateway | Provides the built-in TokenRelay= GatewayFilter |
| Project-Reactor | Reactive context propagation makes token relay non-blocking |
| PKCE | Secures the token acquisition that precedes relay |
Related Security Patterns
- OAuth2-OIDC-Flows — Token Relay distributes tokens acquired via OAuth2 flows; OAuth2-OIDC-Flows covers which flow (Authorization Code, Client Credentials) produces the token being relayed
- JWT — the relayed token is typically a JWT; downstream services validate it using the rules in the JWT note (signature first, then expiry, then audience)
Sources
- Spring Cloud Gateway Reference: TokenRelay filter (docs.spring.io/spring-cloud-gateway)
- Spring Security Reference: Reactive OAuth2 Client (docs.spring.io/spring-security/reference)
- Spring Security Reference:
ServerOAuth2AuthorizedClientExchangeFilterFunction - Baeldung, "Spring Cloud Gateway OAuth2 Token Relay"
Backlinks
- P2-Spring-Boot-BFF-Stack — P2 references Token Relay as a forward reference
- P4-BFF-Security — Phase 4 research covers Token Relay in depth
- BFF-For-SPA — Token Relay is a core step in the SPA security flow