Authentication
Token Exchange
RFC 8693 narrowing scope and delegation for sub-agents
Last verified 2026-09-24
Summary
An exchanges its broad for a narrower, audience-restricted token before delegating to sub-agents. The exchange endpoint validates the subject token, ensures requested is a subset of subject scope, and issues a short-lived token (15 minutes) with the actor chain via the act claim.
Orchestrator (broad token)
↓ POST /oauth/token
├─ grant_type: token-exchange
├─ subject_token: (broad token)
├─ actor_token: (sub-agent ID)
└─ scope: invoices:read (narrower)
↓
Sub-Agent (scoped token)- Subject token validated (signature, issuer, expiry)
- Requested scope must be subset of subject scope (no escalation)
- New token includes
actclaim for delegation chain - Short-lived (15 minutes recommended for delegated tokens)
- Audience-restricted to target service
An orchestrator agent holds a broad token: invoices:read invoices:write customers:read. Before calling a or passing control to a downstream service, the orchestrator should exchange that broad token for a narrower, audience-restricted token. RFC 8693 Token Exchange enables this: a single endpoint that accepts a subject token and returns a new token with reduced scope, different audience, or delegated actor chain.
Token Exchange prevents token amplification - a downstream service cannot take the token it receives and use it beyond its intended scope.
The Exchange Request
The orchestrator POSTs to the token endpoint with grant_type=urn:ietf:params:oauth:grant-type:token-exchange:
curl -X POST https://auth.example.com/oauth/token \
-H "Content-Type: application/x-www-form-urlencoded" \
-d "grant_type=urn:ietf:params:oauth:grant-type:token-exchange" \
-d "subject_token=eyJhbGciOiJSUzI1NiI..." \
-d "subject_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "actor_token=eyJhbGciOiJSUzI1NiI..." \
-d "actor_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "requested_token_type=urn:ietf:params:oauth:token-type:jwt" \
-d "audience=https://invoices-service.example.com/" \
-d "scope=invoices:read"Parameters:
subject_token- the original token (the user's authority or the orchestrator's token)subject_token_type- type of the subject token (usuallyurn:ietf:params:oauth:token-type:jwt)actor_token- optional; the agent acting on behalf of the subject (for delegation chains)actor_token_type- type of the actor tokenrequested_token_type- type of token to return (usuallyurn:ietf:params:oauth:token-type:jwt)audience- the service that will consume the new tokenscope- the scopes to include in the new token (must be a subset of subject token's scopes)
The server returns a new access token:
{
"access_token": "eyJhbGciOiJSUzI1NiI...",
"issued_token_type": "urn:ietf:params:oauth:token-type:jwt",
"token_type": "Bearer",
"expires_in": 900,
"scope": "invoices:read"
}Notice the short expiry: 900 seconds (15 minutes). Exchanged tokens are ephemeral, long enough for the operation but short enough to minimize exposure if they leak.
Use Case: Sub-Agent Delegation
An orchestrator agent calls a sub-agent to summarize invoices. The orchestrator has invoices:read invoices:write customers:read. The sub-agent should only have invoices:read for the specific service.
async function delegateToSubAgent(
orchestratorToken: string,
subAgentToken: string,
downstreamServiceUri: string,
): Promise<string> {
const form = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: orchestratorToken,
subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
actor_token: subAgentToken,
actor_token_type: "urn:ietf:params:oauth:token-type:jwt",
requested_token_type: "urn:ietf:params:oauth:token-type:jwt",
audience: downstreamServiceUri,
scope: "invoices:read",
});
const res = await fetch("https://auth.example.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!res.ok) {
const err = await res.json();
throw new Error(`Token exchange failed: ${err.error}`);
}
const { access_token } = await res.json();
return access_token;
}
// Orchestrator calls sub-agent
const delegatedToken = await delegateToSubAgent(
orchestratorToken,
subAgentSelfSignedToken, // a security token identifying "sub-agent-summarize", not a bare ID
"https://invoices-service.example.com/",
);
// Sub-agent makes request with the narrowed token
const res = await fetch("https://invoices-service.example.com/invoices", {
headers: { Authorization: `Bearer ${delegatedToken}` },
});The sub-agent receives a token that:
- Is scoped to
invoices:readonly (cannot write) - Is only valid for
invoices-service.example.com(audience-restricted) - Expires in 15 minutes (short-lived)
- Includes the orchestrator's identity via the
actclaim
Use Case: User Delegation (On-Behalf-Of)
A human user authorizes an agent to act on their behalf. The identity provider issues a token with the user's sub claim. The agent exchanges it for a token that includes both the user and the agent in the actor chain.
Use Token Exchange when the agent already has a valid subject token or delegation to narrow. If the agent first needs to register or claim a user-bound credential, use auth.md or another explicit registration flow before token exchange.
async function exchangeForDelegatedAccess(
userToken: string,
agentToken: string,
audience: string,
scope: string[],
): Promise<string> {
const form = new URLSearchParams({
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
subject_token: userToken,
subject_token_type: "urn:ietf:params:oauth:token-type:jwt",
actor_token: agentToken,
actor_token_type: "urn:ietf:params:oauth:token-type:jwt",
requested_token_type: "urn:ietf:params:oauth:token-type:jwt",
audience: audience,
scope: scope.join(" "),
});
const res = await fetch("https://auth.example.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: form.toString(),
});
if (!res.ok) throw new Error(`Token exchange failed: ${res.status}`);
const { access_token } = await res.json();
return access_token;
}
// User authorizes agent
const delegatedToken = await exchangeForDelegatedAccess(
userJwt,
agentSelfSignedToken, // a security token identifying "agent-process-invoices", not a bare ID
"https://invoices-service.example.com/",
["invoices:read"],
);The resulting token includes:
sub: the user's IDact: the agent's IDscope:invoices:readonlyaud: the target service
The target service can verify that the user authorized the agent, and the agent is acting within its delegated scope.
Server-Side Validation
When receiving a delegated token, validate both the subject and the actor:
import { jwtVerify, createRemoteJWKSet } from "jose";
const jwks = createRemoteJWKSet(new URL("https://auth.example.com/.well-known/jwks.json"));
async function validateDelegatedToken(token: string) {
const { payload } = await jwtVerify(token, jwks, {
issuer: "https://auth.example.com/",
audience: "https://invoices-service.example.com/",
});
// Verify scopes
const scopes = ((payload.scope as string) || "").split(" ");
if (!scopes.includes("invoices:read")) {
throw new Error("Token lacks required scope");
}
// Verify subject (user)
const userId = payload.sub;
// Verify actor (agent) - included via the `act` claim
const actorId = (payload.act as any)?.sub || payload.azp;
// Log both for audit
console.log(`User ${userId} delegated to agent ${actorId}`);
// Apply authorization rules
return { userId, agentId: actorId, scopes };
}Token Exchange Server Implementation
A minimal token exchange endpoint:
import { SignJWT, jwtVerify } from "jose";
async function handleTokenExchange(req: Request) {
const form = await req.formData();
const grantType = form.get("grant_type");
if (grantType !== "urn:ietf:params:oauth:grant-type:token-exchange") {
return Response.json({ error: "unsupported_grant_type" }, { status: 400 });
}
// Validate subject token
const subjectToken = form.get("subject_token") as string;
let subjectPayload;
try {
const { payload } = await jwtVerify(subjectToken, jwks, {
issuer: "https://auth.example.com/",
});
subjectPayload = payload;
} catch (err) {
return Response.json({ error: "invalid_request" }, { status: 400 });
}
// Validate actor token (optional)
const actorToken = form.get("actor_token") as string;
let actorPayload = null;
if (actorToken) {
try {
const { payload } = await jwtVerify(actorToken, jwks);
actorPayload = payload;
} catch (err) {
return Response.json({ error: "invalid_request" }, { status: 400 });
}
}
// Validate requested scope is a subset of subject scope
const requestedScope = ((form.get("scope") as string) || "").split(" ");
const subjectScope = ((subjectPayload.scope as string) || "").split(" ");
const isValidScope = requestedScope.every((s) => subjectScope.includes(s));
if (!isValidScope) {
return Response.json({ error: "invalid_scope" }, { status: 400 });
}
// Create the new token
const now = Math.floor(Date.now() / 1000);
const audience = form.get("audience") as string;
const newToken = await new SignJWT({
sub: subjectPayload.sub,
act: actorPayload ? { sub: actorPayload.sub } : undefined,
scope: requestedScope.join(" "),
aud: audience,
iss: "https://auth.example.com/",
iat: now,
exp: now + 900, // 15 minutes
})
.setProtectedHeader({ alg: "RS256" })
.sign(privateKey);
return Response.json({
access_token: newToken,
token_type: "Bearer",
expires_in: 900,
issued_token_type: "urn:ietf:params:oauth:token-type:jwt",
});
}Checklist
- Token Exchange endpoint is implemented at
/oauth/token - Subject token is validated (signature, issuer, expiry)
- Actor token is optional but validated if present
- Requested scope is a subset of subject scope (cannot escalate)
- New token includes
sub,act,scope,aud,exp - New token is short-lived (15 minutes recommended for delegated tokens)
- Audience claim is validated server-side on every request
- Both
sub(user/subject) andact(agent) are logged for audit
See Also
- Agent Identity - actor chains and audit logging
- OAuth 2.1 for Agents - token issuance and caching
- Protected Resource Metadata - advertising token exchange support
- auth.md - registering or claiming user-bound credentials before delegation
- RFC 8693: OAuth 2.0 Token Exchange - full specification