Skip to main content

Exchange a token

Token exchange trades a token you hold for a new one. Use it when one agent hands work to another. The new token can carry fewer scopes than the old one, and it can record which agent acted in an act claim.

Before you start​

Set up two agents​

Agent A holds a token with two scopes. Agent B exchanges it for a token with one scope. Both are entitled to client_credentials and to token exchange on urn:mcp:server. Without the token exchange grant type, an exchange is refused with access_denied, and the audit row's reason is grant_type_not_allowed.

agent() {
local resp; resp=$(curl -fsS -X POST "$POLICYARC_URL/register" -H 'Content-Type: application/json' \
-d "{\"client_name\": \"$1\", \"redirect_uris\": [\"https://$1.example/cb\"]}")
curl -fsS -o /dev/null -X PATCH "$POLICYARC_URL/admin/api/policy-data/by-key?dataKey=pbac.operator.controls.client_entitlements&path=client_id.$(jq -r .client_id <<<"$resp")" \
-H "X-Admin-API-Key: $POLICYARC_ADMIN_KEY" -H 'Content-Type: application/json' \
-d '{"grant_types": ["client_credentials", "urn:ietf:params:oauth:grant-type:token-exchange"], "granted_resources": [{"type": "urn:mcp:server", "scopes": ["mcp:tools", "mcp:resources"]}, {"type": "urn:as:introspect", "scopes": ["introspection"]}]}'
jq -r '"\(.client_id):\(.client_secret)"' <<<"$resp"
}
export AGENT_A=$(agent agent-a) AGENT_B=$(agent agent-b)
sleep 5
token() { curl -fsS -u "$1" "$POLICYARC_URL/token" -d grant_type=client_credentials -d "scope=$2" -d resource_types=urn:mcp:server | jq -er .access_token; }
export SUBJECT_TOKEN=$(token "$AGENT_A" 'mcp:tools mcp:resources')
export ACTOR_TOKEN=$(token "$AGENT_B" 'mcp:tools')

Exchange with an actor​

Agent B sends A's token as the subject and its own token as the actor, and asks for mcp:tools only.

resp=$(curl -fsS -u "$AGENT_B" "$POLICYARC_URL/token" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$SUBJECT_TOKEN" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d actor_token="$ACTOR_TOKEN" \
-d actor_token_type=urn:ietf:params:oauth:token-type:access_token \
-d scope=mcp:tools)
echo "$resp"
jq -e '.scope == "mcp:tools" and .issued_token_type == "urn:ietf:params:oauth:token-type:access_token"' <<<"$resp" >/dev/null ||
{ echo 'Expected an mcp:tools access token' >&2; false; }
export EXCHANGED_TOKEN=$(jq -er .access_token <<<"$resp")

The response is a normal token response with "issued_token_type": "urn:ietf:params:oauth:token-type:access_token". Introspection shows who acted.

resp=$(curl -fsS -u "$AGENT_B" "$POLICYARC_URL/introspect" -d token="$EXCHANGED_TOKEN")
echo "$resp"
b_id=${AGENT_B%%:*}
jq -e --arg b "$b_id" '.active and .client_id == $b and .act.sub == $b' <<<"$resp" >/dev/null ||
{ echo 'Expected act.sub to name agent B' >&2; false; }

client_id is the client that made the exchange. act.sub is the actor. It is the actor token's subject when that token has one, or else the actor's client ID. When the subject token was itself exchanged with an actor, the new act nests the earlier one, so the chain of agents stays on the token. When the subject token was issued to a user, the new token keeps that user as its subject.

Ask for more and be refused​

Policy refused this exchange, which reaches more than the subject token was granted. It does not refuse every such exchange, as the known issue below says. Here agent B holds a token for introspection only and asks to exchange it for mcp:tools.

st=$(curl -fsS -u "$AGENT_B" "$POLICYARC_URL/token" -d grant_type=client_credentials -d scope=introspection | jq -er .access_token)
resp=$(curl -sS -w '\n%{http_code}' -u "$AGENT_B" "$POLICYARC_URL/token" \
-d grant_type=urn:ietf:params:oauth:grant-type:token-exchange \
-d subject_token="$st" \
-d subject_token_type=urn:ietf:params:oauth:token-type:access_token \
-d scope=mcp:tools)
echo "$resp"
[ "$(tail -n1 <<<"$resp")" = 400 ] && grep -qF '"error":"access_denied"' <<<"$resp" ||
{ echo 'Expected HTTP 400 access_denied' >&2; false; }

The answer is {"error":"access_denied","error_description":"Access denied"}, and the audit row's reason is token_exchange_actions_exceeded. With no scope, the new token asks for the subject token's scopes.

Known issue. On 0.65.0 policy compares the request with the subject token only when the subject token names a resource. The exchange then inherits that resource, and the check applies to it. Otherwise the server derives the resource from the requested scope and marks it derived, and token.rego skips a derived resource (not r.derived). In our run agent B's introspection token above names the resource urn:as:introspect, so its exchange was refused. Its mcp:tools tokens, requested with or without resource_types=urn:mcp:server, name none, and the server exchanged them for mcp:resources and for introspection. An introspection token requested with resource_types=urn:as:introspect names none either, and the server exchanged it for mcp:tools.

Other refusals we saw.

RequestResponse
subject_token_type of urn:ietf:params:oauth:token-type:jwtinvalid_request, Unsupported subject_token_type
An actor_token the server did not issueinvalid_grant, Invalid or expired actor token
A client without the token exchange grant typeaccess_denied

Every one is HTTP 400. A subject token bound to a DPoP key needs a DPoP proof for that key on the exchange request. Without one the server answers invalid_grant, DPoP proof required for bound subject token, and with a proof for another key invalid_grant, DPoP key mismatch for subject token. Any client with the grant type can present any valid subject token. Write a policy rule if you need to limit who may exchange whose tokens.

Exchange an ID token​

A client can also send an OpenID Connect ID token from another issuer as the subject, with subject_token_type set to urn:ietf:params:oauth:token-type:id_token. The issuer must be in trusted_issuers in pbac.operator.controls.trust, the row that also governs software statements. The server checks this before it fetches the issuer's keys, and refuses any other issuer with invalid_grant, Subject token issuer is not trusted. It then verifies the signature with the issuer's keys and requires sub. The ID token's claims, apart from a few reserved names, become the subject that policy sees.

We have not run an ID token exchange by hand. The integration test test_rfc8693_token_exchange.py covers it, and all 41 of its tests passed on our 0.65.0 instance.

The actor token can also be a SPIFFE JWT-SVID, with actor_token_type set to urn:ietf:params:oauth:token-type:jwt. See SPIFFE workload identity.