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).