I was writing my column for the German magazine Java Spektrum recently. That got me thinking about security standards for agents, especially the rules around token audiences and token forwarding. I wanted to take one part of that discussion and show what it means for a normal Quarkus microservice chain.
Agents make the problem easy to see, but the problem is not specific to agents. An access token issued for an order service has no reason to work at an inventory service. It has even less reason to work at an audit service later in the chain. Still, many implementations copy the incoming Authorization header through the complete request chain and configure every service to accept it.
The user identity remains available, but one token now works across several security boundaries. If it leaks, it can reach much more than its aud claim suggests.
Teams sometimes accept this risk inside a closed network. One platform owns the issuer, all services use the same controls, and forwarding the header is simple. The issue here becomes very visible when calls cross domains and clouds. Each side may use different policies. When another domain accepts the original token, the audience no longer controls where that token works.
The Model Context Protocol authorization specification also mentions this rule explicitly. An MCP server MUST validate that it is the intended audience of the token. If the server calls an upstream API, it MUST NOT forward the token it received from the MCP client. The upstream call needs a separate token. MCP is strict because a server often sits between a client and APIs in another security domain. The same rule applies to normal service chains, even when their protocol does not write it in capital letters.
We will build that service chain with narrow tokens. The user signs in through authorization code flow with Proof Key for Code Exchange (PKCE) and receives a token for Service A. Service A exchanges it before calling Service B. Service B exchanges the new token before calling Service C. The subject stays the same. The audience, authorized party, and token ID change at every hop.
The example uses Quarkus 3.36.0, Java 25, and Keycloak 26.7.0. I tested the complete path with Podman. Come along for the ride if you like.
What we are building
We use three independent Quarkus applications:
order-service, the OAuth clientservice-a, acceptsPOST /orders/{orderId}/submiton port 8081.inventory-service, the OAuth clientservice-b, acceptsPOST /reservationson port 8082.audit-service, the OAuth clientservice-c, acceptsPOST /audit-eventson port 8083.Keycloak authenticates Alice and performs both token exchanges on port 8180.
Four claims let us see what happens:
sub is the stable user identifier. In this realm, Alice’s subject is 11111111-1111-1111-1111-111111111111. It is not her username.
aud names the service allowed to consume the token.
azp identifies the client authorized to request the current token. It changes from tutorial-client to service-a and then service-b.
jti identifies one token. Three different values show that we did not copy one bearer token through the chain.
The applications also carries an X-Correlation-ID. This is not an OAuth claim. It connects the logs from all three services because the final token does not contain the complete actor history.
What you need
JDK 25 on
PATHPodman with Compose support
curl,jq, and OpenSSLThe Quarkus CLI if you want to recreate the three projects
About three ☕️. Security is always hard.
You can start with cloning the complete example from my Github repository or follow along below:
git clone https://github.com/myfear/the-main-thread.git
cd the-main-thread/quarkus-cascaded-delegationEach project has its own Maven wrapper, so you do not need a global Maven installation.
Create the three Quarkus applications
The code is ready to run. These commands create the same three application projects:
quarkus create app dev.mainthread.delegation:order-service \
-P io.quarkus.platform:quarkus-bom:3.36.0 \
--java=25 \
--no-code \
--extensions='rest-jackson,oidc,rest-client-jackson,rest-client-oidc-token-propagation'
quarkus create app dev.mainthread.delegation:inventory-service \
-P io.quarkus.platform:quarkus-bom:3.36.0 \
--java=25 \
--no-code \
--extensions='rest-jackson,oidc,rest-client-jackson,rest-client-oidc-token-propagation'
quarkus create app dev.mainthread.delegation:audit-service \
-P io.quarkus.platform:quarkus-bom:3.36.0 \
--java=25 \
--no-code \
--extensions='rest-jackson,oidc'quarkus-oidc validates incoming bearer tokens. The REST Client token propagation extension handles the bearer token on the outgoing call. We configure it to exchange the current token before that call.
Configure Keycloak for the chain
The Compose file mounts a realm import into Keycloak:
services:
keycloak:
image: quay.io/keycloak/keycloak:26.7.0
command:
- start-dev
- --import-realm
- --health-enabled=true
environment:
KC_BOOTSTRAP_ADMIN_USERNAME: admin
KC_BOOTSTRAP_ADMIN_PASSWORD: admin
ports:
- "8180:8080"
- "9000:9000"
volumes:
- ./keycloak:/opt/keycloak/data/import:ZStart it and wait for the management health endpoint:
podman compose up -d keycloak
curl -fsS http://localhost:9000/health/ready | jq .statusKeycloak is ready when the status is UP.
The full realm import sets up:
A public
tutorial-clientwith authorization code flow and S256 PKCE.A local Alice user with a fixed subject ID.
Confidential clients
service-a,service-b, andservice-c.Standard Token Exchange enabled on
service-aandservice-b.Audience client scopes for
service-a,service-b, andservice-c.A five-minute access-token lifetime.
A client policy using Keycloak’s
downscope-assertion-grant-enforcer.
Service B gets its audience from a normal client scope mapper:
{
"name": "to-service-b",
"description": "Makes service-b available as an audience to service-a",
"protocol": "openid-connect",
"attributes": {
"include.in.token.scope": "false",
"display.on.consent.screen": "false"
},
"protocolMappers": [
{
"name": "service-b audience",
"protocol": "openid-connect",
"protocolMapper": "oidc-audience-mapper",
"consentRequired": false,
"config": {
"included.client.audience": "service-b",
"access.token.claim": "true",
"introspection.token.claim": "true"
}
}
]
}Service A receives this scope by default and has Standard Token Exchange enabled:
{
"clientId": "service-a",
"name": "Order Service",
"enabled": true,
"clientAuthenticatorType": "client-secret",
"secret": "service-a-secret",
"publicClient": false,
"bearerOnly": false,
"standardFlowEnabled": false,
"implicitFlowEnabled": false,
"directAccessGrantsEnabled": false,
"serviceAccountsEnabled": false,
"consentRequired": false,
"fullScopeAllowed": false,
"attributes": {
"standard.token.exchange.enabled": "true"
},
"defaultClientScopes": [
"subject",
"identity",
"to-service-b"
],
"optionalClientScopes": [
"forbidden"
]
}More details can be found in Keycloak’s Standard Token Exchange documentation. The audience parameter filters audiences that are already available through client scopes and roles. It does not create an audience. If Service A’s scopes and roles do not include service-b, requesting audience=service-b gives Keycloak nothing valid to select.
The realm uses the same setup from service-b to service-c. We do not enable exchange on service-c because the chain ends there.
The public client has direct access grants enabled for a few shell-level failure checks. The verifier uses the real authorization code flow for the main path. It loads the Keycloak login form, signs in Alice, follows the registered redirect, and redeems the code with an S256 verifier. Interactive applications should use this PKCE path and disable the password grant.
Reject a token at the wrong service
Every service checks the audience of its incoming token. The order service uses this configuration:
quarkus.http.port=8081
quarkus.oidc.application-type=service
quarkus.oidc.auth-server-url=${KEYCLOAK_URL:http://localhost:8180}/realms/delegation
quarkus.oidc.client-id=service-a
quarkus.oidc.token.audience=service-aInventory changes the port, client ID, and audience to 8082 and service-b. Audit uses 8083 and service-c.
The resources also require an authenticated identity. The audit endpoint is small enough to show in full:
package dev.mainthread.delegation.audit;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.jboss.logging.Logger;
import io.quarkus.security.Authenticated;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/audit-events")
@Authenticated
@ApplicationScoped
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class AuditResource {
private static final Logger LOG = Logger.getLogger(AuditResource.class);
private final JsonWebToken accessToken;
public AuditResource(JsonWebToken accessToken) {
this.accessToken = accessToken;
}
@POST
public ClaimSnapshot record(
@HeaderParam("X-Correlation-ID") String correlationId,
AuditEvent event) {
ClaimSnapshot snapshot = ClaimSnapshot.from("audit-service", accessToken, correlationId);
LOG.infof(
"Audit event correlationId=%s subject=%s client=%s action=%s orderId=%s tokenId=%s",
snapshot.correlationId(),
snapshot.subject(),
snapshot.authorizedParty(),
event.action(),
event.orderId(),
snapshot.tokenId());
return snapshot;
}
}The claim snapshot was build for this example. It lets us compare the token seen by each service without logging or returning the raw bearer token:
package dev.mainthread.delegation.audit;
import java.util.Comparator;
import java.util.List;
import org.eclipse.microprofile.jwt.JsonWebToken;
public record ClaimSnapshot(
String service,
String subject,
String username,
List<String> audience,
String authorizedParty,
String scope,
String tokenId,
String correlationId) {
public ClaimSnapshot {
audience = List.copyOf(audience);
}
public static ClaimSnapshot from(String service, JsonWebToken token, String correlationId) {
List<String> audience = token.getAudience() == null
? List.of()
: token.getAudience().stream().sorted(Comparator.naturalOrder()).toList();
return new ClaimSnapshot(
service,
token.getSubject(),
token.getClaim("preferred_username"),
audience,
token.getClaim("azp"),
token.getClaim("scope"),
token.getTokenID(),
correlationId);
}
}Order and inventory keep the same record in their own packages. This keeps their REST payloads explicit.
Before we add exchange, we can prove why the original token must stop at Service A. Get the local verification token:
TOKEN=$(curl -fsS -X POST \
http://localhost:8180/realms/delegation/protocol/openid-connect/token \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d grant_type=password \
-d client_id=tutorial-client \
-d username=alice \
-d password=alice | jq -r .access_token)Send this token directly to inventory:
curl -i -X POST http://localhost:8082/reservations \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d '{"orderId":"order-42","quantity":1}'Quarkus returns HTTP/1.1 401 Unauthorized. The token has aud=service-a, while inventory requires quarkus.oidc.token.audience=service-b.
Exchange the token from orders to inventory
The order service has two OAuth roles. It is a resource server when it validates Alice’s token. It becomes an OAuth client when it asks Keycloak for a Service B token.
Configure its OIDC client in order-service/src/main/resources/application.properties:
quarkus.oidc-client.auth-server-url=${KEYCLOAK_URL:http://localhost:8180}/realms/delegation
quarkus.oidc-client.client-id=service-a
quarkus.oidc-client.credentials.secret=${SERVICE_A_SECRET}
quarkus.oidc-client.grant.type=exchange
quarkus.oidc-client.grant-options.exchange.audience=service-b
quarkus.oidc-client.grant-options.exchange.subject_token_type=urn:ietf:params:oauth:token-type:access_token
quarkus.oidc-client.grant-options.exchange.requested_token_type=urn:ietf:params:oauth:token-type:access_token
quarkus.oidc-client.connection-timeout=3S
quarkus.oidc-client.connection-retry-count=1
quarkus.rest-client-oidc-token-propagation.exchange-token=true
quarkus.rest-client.inventory.url=${INVENTORY_URL:http://localhost:8082}
quarkus.rest-client.inventory.connect-timeout=3000
quarkus.rest-client.inventory.read-timeout=5000Keycloak 26.7 rejects an exchange without subject_token_type and returns Parameter 'subject_token_type' required for standard token exchange. Set it explicitly. Some shorter Quarkus examples only show audience.
Quarkus 3.36.0 accepts connection-retry-count=0 at startup. The first token request then fails because the underlying retry policy needs a positive retry count. A value of 1 allows one retry, so an outage still returns in time.
The service has one downstream OAuth boundary, so we can use the default OIDC client. Add @AccessToken to the REST Client:
package dev.mainthread.delegation.order;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import io.quarkus.oidc.token.propagation.common.AccessToken;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/reservations")
@RegisterRestClient(configKey = "inventory")
@AccessToken
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface InventoryClient {
@POST
ReservationResult reserve(
@HeaderParam("X-Correlation-ID") String correlationId,
ReservationRequest request);
}The annotation registers the propagation filter. With exchange-token=true, the filter calls the configured OIDC client and puts the exchanged token into the outgoing Authorization: Bearer header.
The order endpoint records its claims, keeps an incoming correlation ID or creates one, and then calls inventory:
package dev.mainthread.delegation.order;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.RestResponse.Status;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
import io.quarkus.security.Authenticated;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/orders")
@Authenticated
@ApplicationScoped
@Produces(MediaType.APPLICATION_JSON)
public class OrderResource {
private static final Logger LOG = Logger.getLogger(OrderResource.class);
private final InventoryClient inventoryClient;
private final JsonWebToken accessToken;
public OrderResource(@RestClient InventoryClient inventoryClient, JsonWebToken accessToken) {
this.inventoryClient = inventoryClient;
this.accessToken = accessToken;
}
@POST
@Path("/{orderId}/submit")
public DelegationTrace submit(
@PathParam("orderId") String orderId,
@HeaderParam("X-Correlation-ID") String incomingCorrelationId) {
String correlationId = incomingCorrelationId == null || incomingCorrelationId.isBlank()
? UUID.randomUUID().toString()
: incomingCorrelationId;
ClaimSnapshot orderHop = ClaimSnapshot.from("order-service", accessToken, correlationId);
logHop(orderHop, "inventory-service");
try {
ReservationResult reservation = inventoryClient.reserve(
correlationId,
new ReservationRequest(orderId, 1));
List<ClaimSnapshot> hops = new ArrayList<>();
hops.add(orderHop);
hops.addAll(reservation.hops());
return new DelegationTrace(orderId, reservation.status(), hops);
} catch (RuntimeException failure) {
throw new DownstreamFailureException("inventory-service", correlationId, failure);
}
}
@ServerExceptionMapper
RestResponse<ErrorResponse> mapDownstreamFailure(DownstreamFailureException failure) {
LOG.errorf(
"Delegation failed correlationId=%s cause=%s",
failure.correlationId(),
failure.getCause().getClass().getSimpleName());
return RestResponse.status(
Status.BAD_GATEWAY,
new ErrorResponse(
"downstream_unavailable",
failure.getMessage(),
failure.correlationId()));
}
private static void logHop(ClaimSnapshot hop, String targetAudience) {
LOG.infof(
"Delegating correlationId=%s subject=%s client=%s targetAudience=%s tokenId=%s",
hop.correlationId(),
hop.subject(),
hop.authorizedParty(),
targetAudience,
hop.tokenId());
}
}DownstreamFailureException and ErrorResponse map exchange and inventory failures to a controlled 502 response. The code never retries by forwarding Alice’s original token.
Exchange again from inventory to audit
Inventory applies the same boundary with its own client identity and target audience:
quarkus.http.port=8082
quarkus.oidc.application-type=service
quarkus.oidc.auth-server-url=${KEYCLOAK_URL:http://localhost:8180}/realms/delegation
quarkus.oidc.client-id=service-b
quarkus.oidc.token.audience=service-b
quarkus.oidc-client.auth-server-url=${KEYCLOAK_URL:http://localhost:8180}/realms/delegation
quarkus.oidc-client.client-id=service-b
quarkus.oidc-client.credentials.secret=${SERVICE_B_SECRET}
quarkus.oidc-client.grant.type=exchange
quarkus.oidc-client.grant-options.exchange.audience=service-c
quarkus.oidc-client.grant-options.exchange.subject_token_type=urn:ietf:params:oauth:token-type:access_token
quarkus.oidc-client.grant-options.exchange.requested_token_type=urn:ietf:params:oauth:token-type:access_token
quarkus.oidc-client.connection-timeout=3S
quarkus.oidc-client.connection-retry-count=1
quarkus.rest-client-oidc-token-propagation.exchange-token=true
quarkus.rest-client.audit.url=${AUDIT_URL:http://localhost:8083}
quarkus.rest-client.audit.connect-timeout=3000
quarkus.rest-client.audit.read-timeout=5000Its Audit REST Client follows the same @AccessToken pattern:
package dev.mainthread.delegation.inventory;
import org.eclipse.microprofile.rest.client.inject.RegisterRestClient;
import io.quarkus.oidc.token.propagation.common.AccessToken;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/audit-events")
@RegisterRestClient(configKey = "audit")
@AccessToken
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public interface AuditClient {
@POST
ClaimSnapshot record(
@HeaderParam("X-Correlation-ID") String correlationId,
AuditEvent event);
}The inventory resource creates its snapshot before the second exchange. Audit returns another snapshot from the Service C token:
package dev.mainthread.delegation.inventory;
import java.util.List;
import org.eclipse.microprofile.jwt.JsonWebToken;
import org.eclipse.microprofile.rest.client.inject.RestClient;
import org.jboss.logging.Logger;
import org.jboss.resteasy.reactive.RestResponse;
import org.jboss.resteasy.reactive.RestResponse.Status;
import org.jboss.resteasy.reactive.server.ServerExceptionMapper;
import io.quarkus.security.Authenticated;
import jakarta.enterprise.context.ApplicationScoped;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path("/reservations")
@Authenticated
@ApplicationScoped
@Consumes(MediaType.APPLICATION_JSON)
@Produces(MediaType.APPLICATION_JSON)
public class ReservationResource {
private static final Logger LOG = Logger.getLogger(ReservationResource.class);
private final AuditClient auditClient;
private final JsonWebToken accessToken;
public ReservationResource(@RestClient AuditClient auditClient, JsonWebToken accessToken) {
this.auditClient = auditClient;
this.accessToken = accessToken;
}
@POST
public ReservationResult reserve(
@HeaderParam("X-Correlation-ID") String correlationId,
ReservationRequest request) {
ClaimSnapshot inventoryHop = ClaimSnapshot.from("inventory-service", accessToken, correlationId);
logHop(inventoryHop, "service-c");
try {
ClaimSnapshot auditHop = auditClient.record(
correlationId,
new AuditEvent(request.orderId(), "inventory-reserved"));
return new ReservationResult(
request.orderId(),
"submitted",
List.of(inventoryHop, auditHop));
} catch (RuntimeException failure) {
throw new DownstreamFailureException("audit-service", correlationId, failure);
}
}
@ServerExceptionMapper
RestResponse<ErrorResponse> mapDownstreamFailure(DownstreamFailureException failure) {
LOG.errorf(
"Delegation failed correlationId=%s cause=%s",
failure.correlationId(),
failure.getCause().getClass().getSimpleName());
return RestResponse.status(
Status.BAD_GATEWAY,
new ErrorResponse(
"downstream_unavailable",
failure.getMessage(),
failure.correlationId()));
}
private static void logHop(ClaimSnapshot hop, String targetAudience) {
LOG.infof(
"Delegating correlationId=%s subject=%s client=%s targetAudience=%s tokenId=%s",
hop.correlationId(),
hop.subject(),
hop.authorizedParty(),
targetAudience,
hop.tokenId());
}
}The logs contain the correlation ID, subject, current client, target audience, and token ID. They never contain the encoded token.
Run the complete chain
Open three terminals and start the services. Audit makes no outgoing exchange, so it needs no client secret:
cd audit-service
./mvnw quarkus:devcd inventory-service
SERVICE_B_SECRET=service-b-secret ./mvnw quarkus:devcd order-service
SERVICE_A_SECRET=service-a-secret ./mvnw quarkus:devReuse the local TOKEN from the earlier check and submit an order:
curl -fsS -X POST http://localhost:8081/orders/order-42/submit \
-H "Authorization: Bearer $TOKEN" \
-H 'X-Correlation-ID: tutorial-run-42' | jq .A successful run returns this response:
{
"orderId": "order-42",
"status": "submitted",
"hops": [
{
"service": "order-service",
"subject": "11111111-1111-1111-1111-111111111111",
"username": "alice",
"audience": ["service-a"],
"authorizedParty": "tutorial-client",
"scope": "",
"tokenId": "onrtro:3068a65c-4f23-34be-e3e9-3aca576251f4",
"correlationId": "tutorial-run-42"
},
{
"service": "inventory-service",
"subject": "11111111-1111-1111-1111-111111111111",
"username": "alice",
"audience": ["service-b"],
"authorizedParty": "service-a",
"scope": "",
"tokenId": "ntrtte:b064f15c-2f93-2146-f08c-c7b7405b03be",
"correlationId": "tutorial-run-42"
},
{
"service": "audit-service",
"subject": "11111111-1111-1111-1111-111111111111",
"username": "alice",
"audience": ["service-c"],
"authorizedParty": "service-b",
"scope": "",
"tokenId": "ntrtte:8d433d77-cc15-81e5-46dd-434908c3829a",
"correlationId": "tutorial-run-42"
}
]
}Compare the three rows. The identity and correlation ID stay the same. The audience moves to the next service. azp names the client that requested each token, and every jti is different.
Prove the failure paths
The verification script checks the security properties and failure paths:
./scripts/verify.shThe script starts Keycloak and reads the live PKCE and client-policy settings from the admin API. Then it builds the three applications, starts the packaged JARs, and runs the complete protocol flow. The output ends with:
PASS: Keycloak is ready
PASS: browser client uses authorization code flow with S256 PKCE
PASS: Keycloak downscope policy is active
PASS: all three Quarkus services build
PASS: all three services are listening
PASS: authorization code flow with S256 PKCE issues the initial token
PASS: A to B to C exchanges preserve identity and narrow each audience
PASS: services B and C reject the original service-a token
PASS: service-b cannot exchange a token that was issued only to service-a
PASS: token exchange cannot add a scope absent from the subject token
PASS: raw RFC 8693 exchanges work with the same realm configuration
PASS: a Keycloak exchange outage returns a controlled 502
All cascaded delegation checks passed.The requester-audience check is important when you test token exchange by hand. It asks service-b to exchange Alice’s original token, which has only service-a as its audience. Keycloak returns HTTP 403 with this OAuth body:
{
"error": "access_denied",
"error_description": "Client is not within the token audience"
}This rule prevents an unrelated client from using a valid token as input for a new exchange.
The scope check asks Service A to add a forbidden scope. Alice’s original token does not contain this scope, so the client policy returns HTTP 400:
{
"error": "invalid_scope",
"error_description": "Scopes [forbidden] not present in the initial access token []"
}The last check gets a valid token, stops Keycloak, and calls Service A. Quarkus can still verify the incoming JWT from its cached key material, but it cannot perform the exchange. The endpoint returns the controlled failure:
{
"code": "downstream_unavailable",
"message": "Call to inventory-service failed",
"correlationId": "keycloak-outage"
}The chain stops exactly here. It never falls back to the original token.
What the final token does not tell you
Each service now receives an audience-constrained token for its part of the on-behalf-of call. Service C’s token does not contain the complete actor history from A to B to C.
At the final hop, azp=service-b identifies the client that requested the token. Service A is no longer present in the token claims. We keep that operational history in the correlation ID and the audit record for each hop.
Keycloak 26.7 also documents a Token Exchange Delegation feature with delegation claims such as may_act. The feature is experimental and must not be used in production. We use the supported Standard Token Exchange path here.
Choose the grant for the trust boundary
Our chain stays inside one Keycloak realm. Standard Token Exchange V2 supports this internal-to-internal case. It exchanges an existing Keycloak token for another Keycloak token that targets a different client in the same realm.
Cross-domain exchange needs a different trust relationship. Keycloak’s JWT Authorization Grant accepts an externally signed JWT assertion. It validates the assertion against a configured identity provider and then issues a local access token. Keycloak recommends this grant as the alternative to legacy external-to-internal Token Exchange V1. It became a supported feature in Keycloak 26.6.
Quarkus OIDC Client can use the JWT bearer grant too. For a REST Client that exchanges the current token before propagation, change the grant type:
quarkus.oidc-client.grant.type=jwt
quarkus.rest-client-oidc-token-propagation.exchange-token=trueThe outgoing token request now uses urn:ietf:params:oauth:grant-type:jwt-bearer and sends the current token as the assertion. Add provider-specific parameters under quarkus.oidc-client.grant-options.jwt.*. The Quarkus OIDC client reference documents this mode.
These two properties only configure the client side. Keycloak must also trust the assertion issuer, link the assertion subject to a local user, allow the confidential client to use the grant, and accept the assertion’s audience. The Service A token in this example targets service-a, so a receiver in another domain cannot use it as an assertion automatically. A simple change from exchange to jwt fails until the authorization-server setup matches the new trust relationship.
Next: Cross-App Access
The Identity Assertion JWT Authorization Grant draft applies this idea to cross-app API access. The current -04 draft calls the pattern Cross-App Access, or XAA. The flow uses an identity provider that already handles single sign-on for the user. The downstream resource authorization server still decides which local access token and permissions it will issue.
XAA combines OAuth 2.0 Token Exchange with the JWT Profile for OAuth 2.0 Authorization Grants. The specification covers more than the final grant_type=jwt-bearer request. It also defines how to get the Identity Assertion JWT Authorization Grant, which claims it contains, how discovery works, and how the receiver processes it. Quarkus can send the JWT bearer grant. A complete XAA implementation also needs those other pieces.
MCP already uses this profile in its Enterprise-Managed Authorization extension. The MCP client requests an ID-JAG from the enterprise identity provider. It then exchanges the ID-JAG for an access token at the MCP server’s authorization server. The identity provider keeps control of enterprise policy, and the resource domain controls the token accepted by its MCP server.
Keycloak 26.7 also has an experimental Identity Assertion JWT Authorization Grant implementation. The IETF document is still a work in progress, and Keycloak requires the identity-assertion-jwt feature flag. I’d say that this is still an experiment for now. It fits systems where Service A and Service B use different authorization domains and the downstream side must control its own access tokens.
Harden the pattern for production
The local realm uses readable secrets and a password grant so the verifier can run without external infrastructure. Replace both in a production deployment.
Keep every exchanged access token short-lived. Keycloak’s Standard Token Exchange does not create an access-token revocation chain, so short lifetimes limit the exposure of a downstream token.
Do not request refresh tokens for these service-to-service hops.
Validate issuer and audience independently at every service, as the three
quarkus.oidc.token.audienceproperties do here.Keep one requested audience per exchange. Broad audience lists recreate the problem under a different token ID.
Load client credentials from a secret manager. Prefer stronger client authentication such as private-key JWT or mutual TLS when the identity provider and deployment support it.
Use TLS for Keycloak and every service connection.
start-dev, local HTTP, fixed passwords, and fixed secrets belong only on a workstation.Keep the token endpoint and REST Client timeouts bounded. Map exchange failures explicitly and never retry by propagating the subject token.
Log the subject, current client, target audience, policy result, correlation ID, and token ID when they help operations. Never log the encoded access token.
Rate-limit the edge operation and monitor token-exchange denials. Repeated requester-audience or scope errors can indicate a broken deployment or an attempted privilege expansion.
The Quarkus OIDC client reference covers client authentication, token acquisition, token propagation, and TLS settings. The exchange request itself follows OAuth 2.0 Token Exchange, RFC 8693.
One user, three narrow credentials
Alice stays the user across all three calls. Each service receives a different token. Inventory gets a token for inventory, and audit gets a token for audit. Keycloak controls who can request each exchange and prevents scope growth.
Each service follows the same rule: validate the token created for this boundary, then exchange it before the next call. The correlation ID connects the logs from all three hops. Each access token stays limited to its immediate recipient.
What started as a German article about security in the agentic age became a very concrete Quarkus blog post. Hope you enjoyed reading it.



