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.
When to use it
Section titled “When to use it”| Situation | Use |
|---|---|
| A service acting for a signed-in user | Token exchange |
| An MCP server calling APIs on behalf of whoever is using the agent | Token exchange |
| A cron job or service calling as itself, no user involved | client_credentials |
| A user signing in through a browser | Authorization Code + PKCE |
The endpoint
Section titled “The endpoint”-
Endpoint:
https://{tenant-name}.{region}.authaction.com/oauth2/token -
Method:
POST -
Grant type:
urn:ietf:params:oauth:grant-type:token-exchange -
Parameters:
Name Required Description grant_typeYes urn:ietf:params:oauth:grant-type:token-exchangeclient_idYes Client id of the service performing the exchange client_secretYes Always required — an exchange mints a credential for a third party, so there is no public-client path subject_tokenYes The token of the identity being acted for, normally the user subject_token_typeYes urn:ietf:params:oauth:token-type:access_tokenactor_tokenNo The acting service’s own token. Present means delegation; absent means impersonation actor_token_typeOnly with actor_tokenurn:ietf:params:oauth:token-type:access_tokenaudienceNo API identifiers the issued token is for scopeNo Space-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 vs impersonation
Section titled “Delegation vs impersonation”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.
Using the Node SDK
Section titled “Using the Node SDK”npm install @authaction/node-sdkimport { 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 handlingconst { 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",});Raw HTTP
Section titled “Raw HTTP”# 1. Your service's own identitycurl -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 onecurl -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"] }'Using your own identity provider
Section titled “Using your own identity provider”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:
| Field | What it is |
|---|---|
| Issuer | The iss claim in their tokens, e.g. https://acme.okta.com. Matched exactly, so copy it from the provider |
| JWKS URL | Where their public keys are published, e.g. https://acme.okta.com/oauth2/v1/keys |
| Audience | Optional. Restricts which of their tokens are accepted |
| Subject claim | Which 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.
Providers with a shared issuer
Section titled “Providers with a shared issuer”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.
MCP servers
Section titled “MCP servers”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 actedimport { 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.
Validating the token in your API
Section titled “Validating the token in your API”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 thispayload.act?.sub; // the service acting for them, if anyAn 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" } }}Security notes
Section titled “Security notes”- 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
actclaim cannot record a lie. may_actis honoured. If the subject token names a permitted actor (RFC 8693 §4.4), only that actor may exchange it.scopemay only narrow. An exchange can never grant more than the subject already had.- Unregistered issuers are never trusted, however well-formed the token.
Errors
Section titled “Errors”| Code | Meaning |
|---|---|
invalid_grant | The subject or actor token failed verification, or expired |
unauthorized_client | The actor is not permitted to act for this subject |
invalid_request | A requested_token_type other than access_token |
invalid_audience | An audience this client cannot request |
audience_required | A 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. }}