Skip to content

Token Exchange for AI Agents & Delegated Access

When a service calls an API for someone, it has usually had one option: a static service credential. It works, but the resulting audit trail records the service and the person disappears — and that credential typically sits in an environment variable, unscoped and long-lived.

Token exchange replaces it. Your service trades the user’s token for a short-lived one that names both of them:

{
"sub": "ops@acme.eu", // who authorised it
"act": { "sub": "payouts-agent@clients" }, // what carried it out
"exp": 1760000300 // 5 minutes
}

This matters most for AI agents. An agent that holds a shared API key can do anything that key can do, on anyone’s behalf, with no record of who asked. An agent using token exchange can only do what the person who asked it could already do — and every call it makes says so.


SituationUse
A service acting for a signed-in userToken exchange
An MCP server calling APIs on behalf of whoever is using the agentToken exchange
A cron job or service calling as itself, no user involvedclient_credentials
A user signing in through a browserAuthorization Code + PKCE

  • Endpoint:

    https://{tenant-name}.{region}.authaction.com/oauth2/token
  • Method: POST

  • Grant type: urn:ietf:params:oauth:grant-type:token-exchange

  • Parameters:

    NameRequiredDescription
    grant_typeYesurn:ietf:params:oauth:grant-type:token-exchange
    client_idYesClient id of the service performing the exchange
    client_secretYesAlways required — an exchange mints a credential for a third party, so there is no public-client path
    subject_tokenYesThe token of the identity being acted for, normally the user
    subject_token_typeYesurn:ietf:params:oauth:token-type:access_token
    actor_tokenNoThe acting service’s own token. Present means delegation; absent means impersonation
    actor_token_typeOnly with actor_tokenurn:ietf:params:oauth:token-type:access_token
    audienceNoAPI identifiers the issued token is for
    scopeNoSpace-delimited. May only narrow the subject token’s scope, never widen it
  • Response:

    {
    "access_token": "eyJhbGciOiJSUzI1NiIs...",
    "issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
    "token_type": "bearer",
    "expires_in": 300
    }

Delegation — send an actor_token. The issued token carries an act claim naming your service. This is what you almost always want: the record shows who authorised the action and what performed it.

Impersonation — omit actor_token. The issued token has no act claim and is indistinguishable from the user acting alone. Nothing downstream can tell a service was involved, which is why it is never applied implicitly.


Terminal window
npm install @authaction/node-sdk
import { createClient } from "@authaction/node-sdk";
const client = createClient({
domain: process.env.AUTHACTION_DOMAIN!, // myapp.eu.authaction.com
clientId: process.env.AUTHACTION_CLIENT_ID!,
clientSecret: process.env.AUTHACTION_CLIENT_SECRET!,
});
// userToken arrives with the request you are handling
const { access_token } = await client.exchangeTokenForUser(userToken, ["https://api.myapp.com"]);
await fetch("https://api.myapp.com/payouts", {
headers: { Authorization: `Bearer ${access_token}` },
});

exchangeTokenForUser makes two calls: one for your service’s own identity, one for the exchange. A service doing this per request should cache the first:

const actor = await client.getM2MToken(["https://api.myapp.com"]); // cache until expiry
const { access_token } = await client.exchangeToken({
subjectToken: userToken,
actorToken: actor.access_token,
audience: ["https://api.myapp.com"],
scope: "payouts:read",
});

Terminal window
# 1. Your service's own identity
curl -X POST https://acme.eu.authaction.com/oauth2/m2m/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "client_credentials",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"audience": ["https://api.acme.eu"]
}'
# 2. Exchange the user's token for a delegated one
curl -X POST https://acme.eu.authaction.com/oauth2/token \
-H 'Content-Type: application/json' \
-d '{
"grant_type": "urn:ietf:params:oauth:grant-type:token-exchange",
"client_id": "YOUR_CLIENT_ID",
"client_secret": "YOUR_CLIENT_SECRET",
"subject_token": "USER_TOKEN",
"subject_token_type": "urn:ietf:params:oauth:token-type:access_token",
"actor_token": "SERVICE_TOKEN_FROM_STEP_1",
"actor_token_type": "urn:ietf:params:oauth:token-type:access_token",
"audience": ["https://api.acme.eu"]
}'

The subject_token does not have to be issued by AuthAction. If your team signs in through Okta, Entra ID, Google Workspace, Auth0, Keycloak or anything else that publishes a JWKS endpoint, register it once and the tokens they already hold can be exchanged directly — no change to how anyone signs in, and no migration of human authentication.

In the dashboard, open Identity Providers and add:

FieldWhat it is
IssuerThe iss claim in their tokens, e.g. https://acme.okta.com. Matched exactly, so copy it from the provider
JWKS URLWhere their public keys are published, e.g. https://acme.okta.com/oauth2/v1/keys
AudienceOptional. Restricts which of their tokens are accepted
Subject claimWhich claim identifies the user. Defaults to sub; use email to map onto your own records

Nothing needs configuring on the provider’s side. AuthAction only reads their public keys, outbound. There is no callback URL, client secret, or application to register over there.

Most providers mint a per-customer issuer — an Okta org, a tenant-specific Entra endpoint — so the issuer alone identifies your organisation.

A few do not. https://accounts.google.com is the issuer for every Google account in existence, and Microsoft’s /common/ endpoint is multi-tenant in the same way. For these, an audience is required: it is the only thing separating your users from everyone else’s. Use the OAuth client id you registered with the provider. AuthAction rejects these without one.


An MCP server is the clearest case. The AI model itself never holds credentials — your MCP server does, and it is the thing making HTTP calls.

User in an MCP client
│ "reconcile last month's payouts"
The model picks a tool
Your MCP server ← holds client_id + secret
│ ① receives the user's token with the request
│ ② exchanges it at AuthAction
│ ③ gets a 5-minute token: sub = user, act = MCP server
Your API ← sees who authorised it, and what acted
import { createClient } from "@authaction/node-sdk";
const client = createClient({
domain: process.env.AUTHACTION_DOMAIN!,
clientId: process.env.AUTHACTION_CLIENT_ID!,
clientSecret: process.env.AUTHACTION_CLIENT_SECRET!,
});
// Cached for the process; refresh shortly before it expires.
const actor = await client.getM2MToken(["https://api.myapp.com"]);
server.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
const userToken = extra.authInfo?.token;
if (!userToken) throw new Error("Not authenticated");
const { access_token } = await client.exchangeToken({
subjectToken: userToken,
actorToken: actor.access_token,
audience: ["https://api.myapp.com"],
});
return callYourApi(access_token, request.params);
});

Your MCP server obtains the user’s token through the MCP authorization spec, which requires the authorization server to support dynamic client registration (RFC 7591). AuthAction supports it at /oauth2/register, so it can act as the authorization server for your MCP servers directly.


The issued token is a normal RS256 access token — verify it exactly as you verify any other, then read act for the audit trail.

import { createVerifier } from "@authaction/node-sdk";
const verifier = createVerifier({
domain: process.env.AUTHACTION_DOMAIN!,
audience: "https://api.myapp.com",
});
const payload = await verifier.verifyToken(token);
payload.sub; // the user who authorised this
payload.act?.sub; // the service acting for them, if any

An act chain nests, with the most recent actor outermost. For user → agent → subagent:

{
"sub": "ops@acme.eu",
"act": {
"sub": "subagent@clients",
"act": { "sub": "agent@clients" }
}
}

  • Exchanged tokens are short-lived — 5 minutes by default, and never longer than the subject token they came from. Revoking a user’s session also kills anything derived from it.
  • The actor must be the authenticated client. A service cannot name a third party as the actor, so the act claim cannot record a lie.
  • may_act is honoured. If the subject token names a permitted actor (RFC 8693 §4.4), only that actor may exchange it.
  • scope may only narrow. An exchange can never grant more than the subject already had.
  • Unregistered issuers are never trusted, however well-formed the token.

CodeMeaning
invalid_grantThe subject or actor token failed verification, or expired
unauthorized_clientThe actor is not permitted to act for this subject
invalid_requestA requested_token_type other than access_token
invalid_audienceAn audience this client cannot request
audience_requiredA shared-issuer provider registered without an audience

With the SDK these arrive as AuthActionTokenError, carrying status and code:

import { AuthActionTokenError } from "@authaction/node-sdk";
try {
await client.exchangeToken({ subjectToken: userToken });
} catch (e) {
if (e instanceof AuthActionTokenError && e.code === "invalid_grant") {
// The user's token expired — send them back through login.
}
}