Token Relay Pattern

Token Relay Pattern

The mechanism by which a BFF or API Gateway forwards a user's OAuth2 access token as a Bearer header 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

  1. BFF is the only party that reads from the session — downstream services receive standard Bearer tokens, not session cookies
  2. Token relay is transparent to Angular — the Angular app makes ordinary HTTP calls; Bearer token injection happens in the BFF layer
  3. Spring Cloud Gateway has TokenRelay as a built-in filter — one line of configuration activates it for a route
  4. For WebClient calls (custom aggregation), use ServerOAuth2AuthorizedClientExchangeFilterFunction — this handles extraction + injection programmatically
  5. Silent refresh is part of token relay — if the access token is expired, the BFF refreshes it before relaying

How It Works

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:

  1. Filter checks OAuth2AuthorizedClient.getAccessToken().getExpiresAt()
  2. If expired, calls the IdP /token endpoint with grant_type=refresh_token
  3. Updates the OAuth2AuthorizedClient in ServerOAuth2AuthorizedClientRepository (which persists to Redis)
  4. Injects the new access_token into the outbound request
  5. 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-configures TokenRelayGatewayFilterFactory when spring-boot-starter-oauth2-client is present. No @Bean needed 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, SaveSession must precede TokenRelay to 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.


ConceptRelationship
OAuth2-BFF-PatternToken acquisition; Token Relay is the distribution phase
BFF-For-SPAFull end-to-end flow that uses Token Relay
Spring-Cloud-GatewayProvides the built-in TokenRelay= GatewayFilter
Project-ReactorReactive context propagation makes token relay non-blocking
PKCESecures the token acquisition that precedes relay
  • 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"