Token Relay and Resource Servers

Every Service Validates the Token

Do not let downstream services trust the gateway blindly. Each service is a resource server: it checks the signature with the issuer's public keys (cached), and the issuer, expiry and audience claims. Validation is local and takes microseconds; there is no call to Keycloak per request.

order-service

application.yml

spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: http://keycloak:8180/realms/shopease
          audiences: order-service          # reject tokens meant for other services (Boot 3.2+)
@Configuration
@EnableMethodSecurity
public class ResourceServerConfig {

    @Bean
    SecurityFilterChain api(HttpSecurity http) throws Exception {
        return http
                .csrf(AbstractHttpConfigurer::disable)
                .sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
                .authorizeHttpRequests(auth -> auth
                        .requestMatchers("/actuator/health/**").permitAll()
                        .requestMatchers(HttpMethod.POST, "/api/orders").hasAuthority("SCOPE_orders:write")
                        .anyRequest().authenticated())
                .oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(keycloakRoles())))
                .build();
    }

    // Keycloak puts roles in "realm_access": {"roles": ["CUSTOMER", "ADMIN"]}; add them as ROLE_ authorities
    private JwtAuthenticationConverter keycloakRoles() {
        JwtGrantedAuthoritiesConverter scopes = new JwtGrantedAuthoritiesConverter();   // SCOPE_xxx
        JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
        converter.setJwtGrantedAuthoritiesConverter(jwt -> {
            Collection<GrantedAuthority> authorities = new ArrayList<>(scopes.convert(jwt));
            Map<String, Object> realm = jwt.getClaimAsMap("realm_access");
            if (realm != null && realm.get("roles") instanceof Collection<?> roles) {
                roles.forEach(r -> authorities.add(new SimpleGrantedAuthority("ROLE_" + r)));
            }
            return authorities;
        });
        return converter;
    }
}

@RestController
@RequestMapping("/api/orders")
public class OrderController {
    @GetMapping("/{id}")
    @PreAuthorize("hasRole('ADMIN') or @orderAccess.isOwner(#id, authentication.name)")
    public OrderResponse get(@PathVariable Long id) { ... }
}

Example: The Checks a Resource Server Makes

Example

Signature checking is shown in the Spring Security material; this program focuses on the claim checks that differ per service. A user token is fine for order-service, but payment-service rejects it because the audience is wrong. Expired tokens and foreign issuers get 401; a valid token without the needed scope gets 403. The "current time" is fixed:

import java.util.*;

public class Main {
    static final String ISSUER = "https://auth.shopease.in/realms/shopease";
    static final long NOW = 1_760_000_000L;            // fixed clock (epoch seconds)

    public static void main(String[] args) {
        Map<String, Object> user = token(ISSUER, "user-7", List.of("order-service"), NOW + 300, "orders:read orders:write");
        Map<String, Object> expired = token(ISSUER, "user-7", List.of("order-service"), NOW - 60, "orders:write");
        Map<String, Object> foreign = token("https://evil.example.com", "user-7", List.of("order-service"), NOW + 300, "orders:write");
        Map<String, Object> readOnly = token(ISSUER, "user-9", List.of("order-service"), NOW + 300, "orders:read");
        Map<String, Object> service = token(ISSUER, "service-account-order-service", List.of("payment-service"), NOW + 300, "payments:write");

        System.out.println("user token     -> order-service   : " + check("order-service", "orders:write", user));
        System.out.println("user token     -> payment-service : " + check("payment-service", "payments:write", user));
        System.out.println("expired token  -> order-service   : " + check("order-service", "orders:write", expired));
        System.out.println("foreign issuer -> order-service   : " + check("order-service", "orders:write", foreign));
        System.out.println("read-only user -> order-service   : " + check("order-service", "orders:write", readOnly));
        System.out.println("service token  -> payment-service : " + check("payment-service", "payments:write", service));
    }

    static Map<String, Object> token(String iss, String sub, List<String> aud, long exp, String scope) {
        return Map.of("iss", iss, "sub", sub, "aud", aud, "exp", exp, "scope", scope);
    }

    static String check(String service, String requiredScope, Map<String, Object> claims) {
        if (!ISSUER.equals(claims.get("iss"))) return "401 invalid_token (untrusted issuer)";
        if ((long) claims.get("exp") <= NOW) return "401 invalid_token (expired)";
        if (!((List<?>) claims.get("aud")).contains(service)) return "401 invalid_token (audience is not " + service + ")";
        List<String> scopes = Arrays.asList(((String) claims.get("scope")).split(" "));
        if (!scopes.contains(requiredScope)) return "403 insufficient_scope (needs " + requiredScope + ")";
        return "200 OK as " + claims.get("sub");
    }
}

Output

user token     -> order-service   : 200 OK as user-7
user token     -> payment-service : 401 invalid_token (audience is not payment-service)
expired token  -> order-service   : 401 invalid_token (expired)
foreign issuer -> order-service   : 401 invalid_token (untrusted issuer)
read-only user -> order-service   : 403 insufficient_scope (needs orders:write)
service token  -> payment-service : 200 OK as service-account-order-service

Forwarding the User Token Downstream

When order-service calls inventory-service on behalf of the user, it forwards the same bearer token, so inventory-service knows who the user is:

@Bean
RestClient inventoryClient(RestClient.Builder builder) {
    return builder.baseUrl("http://inventory-service")
            .requestInterceptor((request, body, execution) -> {
                Authentication auth = SecurityContextHolder.getContext().getAuthentication();
                if (auth instanceof JwtAuthenticationToken jwt) {
                    request.getHeaders().setBearerAuth(jwt.getToken().getTokenValue());
                }
                return execution.execute(request, body);
            })
            .build();
}

// OpenFeign version
@Bean
RequestInterceptor bearerTokenRelay() {
    return template -> {
        if (SecurityContextHolder.getContext().getAuthentication() instanceof JwtAuthenticationToken jwt) {
            template.header(HttpHeaders.AUTHORIZATION, "Bearer " + jwt.getToken().getTokenValue());
        }
    };
}

Note: Forwarding works for synchronous calls in the same request thread. A Kafka consumer or an @Scheduled job has no user and no token to forward: use service-to-service authentication (next topic). For strict audience checks, an authorization server can exchange the user token for one aimed at the next service (OAuth2 Token Exchange, RFC 8693).