OAuth2 at the Gateway

Two Ways to Use the Gateway

ModeWho holds the token?Gateway roleBest for
Resource serverThe client (SPA, mobile app) sends Authorization: BearerValidates the JWT and forwards the headerMobile apps, public APIs, SPAs with their own OIDC library
OAuth2 client (BFF)The gateway keeps tokens in a server session; browser only gets a cookiePerforms login, then adds the token with the TokenRelay filterBrowser apps: tokens never reach JavaScript

Gateway as a Resource Server

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-oauth2-resource-server</artifactId>
</dependency>

api-gateway

application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://keycloak:8180/realms/shopease   # downloads the JWKS keys from here
  cloud:
    gateway:
      routes:
        - id: products
          uri: lb://product-service
          predicates:
            - Path=/api/products/**
        - id: orders
          uri: lb://order-service
          predicates:
            - Path=/api/orders/**
@Configuration
@EnableWebFluxSecurity            // Spring Cloud Gateway runs on WebFlux
public class GatewaySecurityConfig {

    @Bean
    SecurityWebFilterChain gatewaySecurity(ServerHttpSecurity http) {
        return http
                .csrf(ServerHttpSecurity.CsrfSpec::disable)                  // stateless bearer-token API
                .authorizeExchange(ex -> ex
                        .pathMatchers(HttpMethod.GET, "/api/products/**").permitAll()   // catalogue is public
                        .pathMatchers("/actuator/health/**").permitAll()
                        .anyExchange().authenticated())
                .oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
                .build();
    }
}

Note: The gateway forwards the Authorization header to the routed service unchanged, so the token reaches order-service automatically. Spring Cloud Gateway 2025.x moves route properties to spring.cloud.gateway.server.webflux.routes; the old prefix still works with a deprecation warning.

Gateway as OAuth2 Client with Token Relay

api-gateway (BFF mode)

For a browser app, let the gateway do the login and keep the tokens. TokenRelay adds the logged-in user's access token to every proxied request:

application.yml

spring:
  security:
    oauth2:
      client:
        provider:
          keycloak:
            issuer-uri: http://keycloak:8180/realms/shopease
        registration:
          shopease-web:
            provider: keycloak
            client-id: shopease-web
            client-secret: ${KEYCLOAK_CLIENT_SECRET}      # from the environment, never committed
            authorization-grant-type: authorization_code
            scope: openid,profile,email
  cloud:
    gateway:
      default-filters:
        - TokenRelay=                # Authorization: Bearer <access token> on every route
        - SaveSession                # persist the session (needed with Spring Session/Redis)

Example output

GET http://localhost:8080/api/orders   (no session cookie)
HTTP/1.1 302 Found
Location: /oauth2/authorization/shopease-web   -> Keycloak login page

... after login ...
GET http://localhost:8080/api/orders   Cookie: SESSION=8c1f...
-> gateway forwards to order-service with  Authorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6...
HTTP/1.1 200 OK
[{"id":1001,"status":"CONFIRMED","total":2998.00}]
@Bean
SecurityWebFilterChain bffSecurity(ServerHttpSecurity http) {
    return http
            .authorizeExchange(ex -> ex
                    .pathMatchers("/", "/assets/**", "/api/products/**").permitAll()
                    .anyExchange().authenticated())
            .oauth2Login(Customizer.withDefaults())                      // redirect to Keycloak
            .csrf(csrf -> csrf.csrfTokenRepository(CookieServerCsrfTokenRepository.withHttpOnlyFalse()))
            .build();                                                    // cookies => keep CSRF ON
}

Tip: Add spring-boot-starter-oauth2-client for BFF mode. Because the browser authenticates with a cookie, CSRF protection must stay enabled (the React app reads the XSRF-TOKEN cookie and sends it back as a header).