Letting AI agents act for users: the RFC 8693 token exchange
AI agents are getting access to the same systems people use: mail, calendars, files, directories. That raises an awkward question. When an agent calls Microsoft Graph, whose access is it? If the answer is "the user's", the agent can do anything the user can do. If the answer is "the agent's own ser

AI agents are getting access to the same systems people use: mail, calendars, files, directories. That raises an awkward question. When an agent calls Microsoft Graph, whose access is it? If the answer is "the user's", the agent can do anything the user can do. If the answer is "the agent's own service account", the audit log stops saying who asked for what.
This series describes how we handled that in ping-entra-broker, a token broker that sits between PingFederate (PF) and Microsoft Entra ID. The broker issues an agent a token that names both the user it acts for and the agent doing the work, and it limits that token to the one resource the agent asked for. This first part covers the front door: the exchange request, the resource map that decides what is allowed, and the two ways the result can be used.
A user signs in to PingFederate. PingFederate federates with Entra over OIDC, so the user's identity lives in Entra. When an agent then needs to reach a downstream API, it needs two things:
Two rules shaped the design. The agent never builds Entra scope strings, because that is an Entra detail that changes and that agents get wrong. And the agent never holds the user's Entra refresh token. The broker keeps the refresh token and does the exchange on the agent's behalf.
We chose RFC 8693 token exchange as the entry point. It is a standard request with a clear shape: a subject token (whose access this is), an actor token (who is acting), and a target. The target is named with resource, which RFC 8707 defines for exactly this use.
POST /broker/oauth/token Content-Type: application/x-www-form-urlencoded grant_type=urn:ietf:params:oauth:grant-type:token-exchange &subject_token=<the user's PF access token> &subject_token_type=urn:ietf:params:oauth:token-type:access_token &actor_token=<the agent's signed client assertion> &actor_token_type=urn:ietf:params:oauth:token-type:jwt &client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer &client_assertion=<the same signed assertion> &resource=https://graph.microsoft.com &scope=User.Read Enter fullscreen mode Exit fullscreen mode Each part does a job:
Two details matter. The broker verifies the assertion's audience, so one captured assertion cannot be used at another service. And it records each assertion's jti in its store, so a replayed assertion is refused. We found the replay check is what stops a captured assertion from being redeemed twice.
The agent asks for a resource by its URI. The broker never trusts that URI on its own. It looks the URI up in a versioned resource map, which is a YAML file that records what each resource is, which tenant it lives in, which Entra audience and scopes it needs, and how it may be delivered.
Here is a trimmed version of the sample map shipped with the repository:
version: 1 resources: - resource: https://api.contoso.com/orders type: api tenant: 11111111-1111-1111-1111-111111111111 entra_audience: api://orders-api scopes: [ orders.read, orders.write ] delivery: { default: proxy, allow_token_mode: false } max_hops: 2 method_scopes: { GET: orders.read, PATCH: orders.write, DELETE: orders.write } rebac: required: true object_from: path object_type: order relations: { GET: viewer, PATCH: editor, DELETE: owner } - resource: https://graph.microsoft.com type: api tenant: home entra_audience: https://graph.microsoft.com scopes: [ User.Read, Mail.Read ] delivery: { default: token, allow_token_mode: true } max_hops: 1 agents: - pf_client_id: report-writer entra_agent_identity: { home: " 00000000-0000-0000-0000-000000000002" } allow: https://api.contoso.com/orders: [ orders.read ] https://graph.microsoft.com: [ User.Read ] allow_token_mode: true max_hops: 2 Enter fullscreen mode Exit fullscreen mode Read it as three checks the broker makes, in order:
The max_hops value bounds agent chains. If agent A asks the broker for a token that agent B then uses to call the broker again, each hop counts. The chain can never be longer than the smallest limit along it, and no hop can widen the rights of the one before it.
Suppose report-writer wants to read the user's profile from Graph in token mode. Its request names User.Read, which the map allows for it, and Graph is marked as token mode. The broker performs the exchange and returns a response of this shape. The values are illustrative and the token is shortened:
{ "access_token": "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiJ9...", "token_type": "Bearer", "expires_in": 3599, "issued_token_type": "urn:ietf:params:oauth:token-type:access_token", "scope": "User.Read", "delivery": "token" } Enter fullscreen mode Exit fullscreen mode The agent can now call Graph directly with that token. The scope field shows the agent exactly what it received, which is the narrow set, not everything the user can do.
Now suppose the same agent asks for orders.write on the orders API. The resource exists and the agent is known, but orders.write is not in its allow list. The broker answers with an error in the RFC 6749 shape, and it writes an audit event recording the decision:
{ "error": "invalid_scope", "error_description": "scope orders.write is not allowed for this agent on this resource" } Enter fullscreen mode Exit fullscreen mode The refusal is the point of the design. The agent learns what it may not do, and the audit log records that it tried.
Once the broker has an Entra token for the agent, it can deliver the result in one of two ways.
A proxy request from the agent looks like this:
curl -sS 'https://broker.example.com/broker/proxy/orders/orders/1001' \ -H 'authorization: Bearer $PROXY_TOKEN' Enter fullscreen mode Exit fullscreen mode The $PROXY_TOKEN is the proxy-scoped token the broker issued in the exchange response, not the Entra token. The Entra token never leaves the broker. In the demo console this curl view is generated for every step, with tokens replaced by names such as $USER_TOKEN so the shape is visible but nothing is usable.
Without a single exchange point, every agent would hold its own copy of the Entra logic: scopes, audiences, refresh handling and retries. Each copy would drift. Putting the logic behind one endpoint means a resource is added in one file, the policy is checked in one place, and a bad scope is caught by the broker rather than by each agent.
The next part covers what happens behind the endpoint: how the broker turns the user's stored refresh token into a token for the agent, and why that takes an agent identity and not just a client secret.