User Context Propagation and the Client Credentials Flow
User Context Propagation and the Client Credentials Flow
The Assumption
Service A receives a user’s JWT. Service A needs to call Service B on behalf of that user. The assumption: forward the JWT.
Three problems. The JWT’s audience claim is for Service A, not Service B. The JWT may expire during a long service chain. And Service B now holds a token it can replay against Service A or any other service that accepts the same issuer.
JWT forwarding is the service-to-service equivalent of credential sharing. It works until someone audits the audience claims and realizes tokens are accepted by services they were never intended for.
The Attack
Token replay across service boundaries. The API Gateway forwards Alice’s JWT to Core API. Core API forwards the same JWT to the Billing Service. The Billing Service now holds a token that is valid for Core API (the audience is core-api). If the Billing Service is compromised, the attacker can replay this token against Core API, acting as Alice.
The intended flow was one-directional: Gateway → Core API → Billing. The JWT makes it bidirectional: anyone holding the token can call any service that accepts the same issuer.
Scope escalation via forwarding. Alice’s token has scopes tenant:read tenant:write billing:read. The API Gateway forwards this token to the Analytics Service, which only needs analytics:read. The Analytics Service receives a token with write permissions it should never have. A bug in the Analytics Service that echoes the token (in an error response, a log, a debug endpoint) leaks a token with write access.
The Spec or Mechanism
Three Propagation Patterns
The three patterns represent an escalation in security maturity. JWT Forwarding passes the user’s original token to every downstream service, creating audience mismatch, scope leakage, and replay risk. Token Exchange (RFC 8693) mints a new, audience-restricted token for each service call, reducing the blast radius of any leak to a single service with minimal scope. Client Credentials removes user context entirely, used when a service acts on its own behalf for batch jobs or system notifications.
Decision Rule
| Scenario | Pattern | Rationale |
|---|---|---|
| User action triggering downstream calls | Token Exchange | User identity needed, scope must be reduced |
| Service responding to a queue message | Client Credentials | No user context in the message |
| Scheduled job processing data | Client Credentials | Service acts as itself |
| Admin accessing user data for support | Token Exchange with admin SVID | Admin identity + user context both needed |
The Implementation
Token Exchange for User Context Propagation
The token exchange flow was introduced in CH6-S2. Here, we integrate it into the outbound service call:
@Component
public class TokenExchangeRequestInterceptor implements ClientHttpRequestInterceptor {
private final RestClient authServerClient;
private final String clientId;
private final String clientSecret;
@Override
public ClientHttpResponse intercept(HttpRequest request, byte[] body,
ClientHttpRequestExecution execution) throws IOException {
// Get the current user's token from SecurityContext
JwtAuthenticationToken auth = (JwtAuthenticationToken)
SecurityContextHolder.getContext().getAuthentication();
String subjectToken = auth.getToken().getTokenValue();
// Determine target audience from the request URI
String audience = resolveAudience(request.getURI());
// Exchange the token
String exchangedToken = exchangeToken(subjectToken, audience);
// Set the exchanged token on the outbound request
request.getHeaders().setBearerAuth(exchangedToken);
return execution.execute(request, body);
}
private String exchangeToken(String subjectToken, String audience) {
Map<String, String> response = authServerClient.post()
.uri("/oauth2/token")
.headers(h -> h.setBasicAuth(clientId, clientSecret))
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
.body("grant_type=urn:ietf:params:oauth:grant-type:token-exchange"
+ "&subject_token=" + subjectToken
+ "&subject_token_type=urn:ietf:params:oauth:token-type:access_token"
+ "&audience=" + audience)
.retrieve()
.body(new ParameterizedTypeReference<>() {});
return response.get("access_token");
}
private String resolveAudience(URI uri) {
// Map service hosts to audience identifiers
return switch (uri.getHost()) {
case "billing.internal" -> "billing-service";
case "analytics.internal" -> "analytics-service";
case "notification.internal" -> "notification-service";
default -> throw new IllegalArgumentException(
"Unknown service host: " + uri.getHost());
};
}
}
RestClient Configuration with Token Exchange
@Configuration
public class ServiceClientConfig {
@Bean
public RestClient billingClient(
TokenExchangeRequestInterceptor tokenExchange) {
return RestClient.builder()
.baseUrl("https://billing.internal")
.requestInterceptor(tokenExchange)
.build();
}
@Bean
public RestClient analyticsClient(
TokenExchangeRequestInterceptor tokenExchange) {
return RestClient.builder()
.baseUrl("https://analytics.internal")
.requestInterceptor(tokenExchange)
.build();
}
}
Client Credentials for Service-Initiated Calls
@Configuration
public class ClientCredentialsConfig {
@Bean
public OAuth2AuthorizedClientManager authorizedClientManager(
ClientRegistrationRepository clientRegistrations,
OAuth2AuthorizedClientService authorizedClientService) {
OAuth2AuthorizedClientProvider authorizedClientProvider =
OAuth2AuthorizedClientProviderBuilder.builder()
.clientCredentials()
.build();
AuthorizedClientServiceOAuth2AuthorizedClientManager clientManager =
new AuthorizedClientServiceOAuth2AuthorizedClientManager(
clientRegistrations, authorizedClientService);
clientManager.setAuthorizedClientProvider(authorizedClientProvider);
return clientManager;
}
}
# application.yml - Client credentials registration
spring:
security:
oauth2:
client:
registration:
notification-service:
provider: auth-server
client-id: core-api
client-secret: ${CORE_API_CLIENT_SECRET}
authorization-grant-type: client_credentials
scope: notification:send
provider:
auth-server:
token-uri: https://auth.saas.example/oauth2/token
// Service calling Notification Service with client credentials
@Service
public class NotificationClient {
private final RestClient restClient;
private final OAuth2AuthorizedClientManager clientManager;
public void sendNotification(String tenantId, String userId, String message) {
// Get client credentials token (no user context)
OAuth2AuthorizeRequest authorizeRequest = OAuth2AuthorizeRequest
.withClientRegistrationId("notification-service")
.principal("core-api") // Service identity, not user
.build();
OAuth2AuthorizedClient client = clientManager.authorize(authorizeRequest);
restClient.post()
.uri("https://notification.internal/send")
.headers(h -> h.setBearerAuth(client.getAccessToken().getTokenValue()))
.body(new NotificationRequest(tenantId, userId, message))
.retrieve()
.toBodilessEntity();
}
}
Vulnerable vs Hardened Comparison
// VULNERABLE: Forward user JWT to all downstream services
@Service
public class VulnerableBillingClient {
public Invoice getInvoice(String invoiceId) {
JwtAuthenticationToken auth = (JwtAuthenticationToken)
SecurityContextHolder.getContext().getAuthentication();
return restClient.get()
.uri("/invoices/{id}", invoiceId)
.headers(h -> h.setBearerAuth(auth.getToken().getTokenValue()))
// This token's audience is "core-api", not "billing-service"
// Billing service must accept tokens for core-api (breaks audience isolation)
// Token has core-api scopes, not billing-specific scopes
.retrieve()
.body(Invoice.class);
}
}
// HARDENED: Token exchange scoped to billing service
@Service
public class HardenedBillingClient {
private final RestClient billingClient; // Configured with TokenExchangeRequestInterceptor
public Invoice getInvoice(String invoiceId) {
return billingClient.get()
.uri("/invoices/{id}", invoiceId)
// Interceptor exchanges the user's token for one scoped to billing-service
// Exchanged token: aud=billing-service, scope=billing:read, act={sub: core-api}
.retrieve()
.body(Invoice.class);
}
}
The Verification
@SpringBootTest
@AutoConfigureMockMvc
class UserContextPropagationTest {
@Autowired
private MockMvc mockMvc;
@MockBean
private RestClient billingClient;
@Test
void tokenExchangeReducesScope() throws Exception {
// User token with broad scopes
Jwt userJwt = Jwt.withTokenValue("user-token")
.header("alg", "RS256")
.claim("sub", "alice")
.claim("tenant_id", "acme-corp")
.claim("scope", List.of("tenant:read", "tenant:write", "billing:read"))
.claim("aud", List.of("core-api"))
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
mockMvc.perform(get("/api/billing/invoices")
.with(jwt().jwt(userJwt)))
.andExpect(status().isOk());
// Verify the outbound call to billing used an exchanged token
// with reduced scope, not the original user token
ArgumentCaptor<HttpRequest> requestCaptor =
ArgumentCaptor.forClass(HttpRequest.class);
verify(billingClient).get();
// In real implementation, verify the interceptor's exchange call
}
@Test
void clientCredentialsDoNotIncludeUserContext() throws Exception {
// Trigger a notification (service-initiated, no user context)
mockMvc.perform(post("/api/admin/broadcast-notification")
.with(jwt().jwt(adminJwt()))
.contentType(MediaType.APPLICATION_JSON)
.content("{\"message\": \"Scheduled maintenance tonight\"}"))
.andExpect(status().isOk());
// Verify notification service was called with client credentials
// (sub = core-api, not sub = admin-user)
}
@Test
void forwardedTokenWithWrongAudienceIsRejected() throws Exception {
// Create a token intended for core-api
Jwt coreApiToken = Jwt.withTokenValue("core-api-token")
.header("alg", "RS256")
.claim("sub", "alice")
.claim("aud", List.of("core-api")) // Wrong audience for billing
.claim("iss", "https://auth.saas.example")
.issuedAt(Instant.now())
.expiresAt(Instant.now().plusSeconds(300))
.build();
// Billing service should reject this token
// (audience is core-api, not billing-service)
mockMvc.perform(get("/internal/billing/invoices")
.with(jwt().jwt(coreApiToken)))
.andExpect(status().isUnauthorized());
}
}
The third test validates audience isolation: a token issued for core-api is rejected by the billing service because the audience does not match. This is the defense against JWT forwarding. If someone removes the audience validator from the billing service’s JWT decoder, this test catches the regression.