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

Only applications registered as agents may exchange tokens. Acting on a person’s behalf is a materially larger power than calling an API as yourself, so it is not granted to every client that happens to hold a secret: an ordinary machine-to-machine application will be refused with unauthorized_client.

In the dashboard, open Applications → Agents and click Create Agent, then enable the APIs it calls on its APIs tab. See Agents & Trusted Issuers for the full setup.

  • 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_idYes, unless client_assertion is sentClient id of the service performing the exchange
    client_secretYes, unless client_assertion is sentThe caller must always authenticate: an exchange mints a credential for a third party, so there is no public-client path. See Without a stored secret
    client_assertionNoA workload token from a trusted issuer linked to the agent, in place of client_id and client_secret
    client_assertion_typeOnly with client_assertionurn:ietf:params:oauth:client-assertion-type:jwt-bearer
    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 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 Trusted Issuers 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.


Everything above still leaves a client_secret in an environment variable. It is long-lived and says nothing about which workload is using it, which undoes much of what a short-lived delegated token buys: the user’s credential became attributable and revocable while the agent’s stayed permanent.

If your agent runs somewhere that issues its own identity, present that instead. Kubernetes, EKS, GKE, GitHub Actions and SPIFFE all mint short-lived credentials their platform rotates, and all of them are OIDC tokens with a JWKS endpoint, so AuthAction verifies them the same way it verifies a user’s token.

Two steps, both one-time:

  1. Register the platform as a trusted issuer (Trusted Issuers in the dashboard): its issuer and JWKS URL.
  2. Link the workload to your agent: on the agent’s Trusted Issuers tab, the exact sub of its tokens and the audience they carry. The issuer can also be registered from there. See Link a trusted issuer to an agent.
PlatformIssuerSubject
Kubernetes / EKShttps://oidc.eks.<region>.amazonaws.com/id/<cluster>system:serviceaccount:<namespace>:<name>
GitHub Actionshttps://token.actions.githubusercontent.comrepo:<org>/<repo>:ref:refs/heads/main

The subject is matched exactly: wildcards are not supported. token.actions.githubusercontent.com issues tokens for every repository on GitHub, so a pattern like repo:acme/* would let any repository in the organisation authenticate as your agent. That is the best-known mistake in cloud workload federation, so the option is not offered.

An audience is required. A workload token is minted with an audience by whoever projects it, and pinning it is what stops a token issued for the Kubernetes API being replayed here. Request one explicitly:

# Kubernetes: project a token scoped to AuthAction
volumes:
- name: authaction-token
projected:
sources:
- serviceAccountToken:
path: token
audience: authaction
expirationSeconds: 3600
import { createClient, workloadTokenFromFile } from "@authaction/node-sdk";
const client = createClient({
domain: process.env.AUTHACTION_DOMAIN!,
clientAssertion: workloadTokenFromFile("/var/run/secrets/authaction/token"),
});
// Exactly as before — but with no secret anywhere.
const { access_token } = await client.exchangeTokenForUser(userToken, ["https://api.myapp.com"]);

clientId and clientSecret are both unnecessary: the credential identifies the workload, and AuthAction resolves it to your agent.

Pass a function, not a string. These credentials rotate, the kubelet rewrites the service-account token in place roughly hourly, so a value read once at startup stops verifying part-way through the day. workloadTokenFromFile() re-reads on every request.

Terminal window
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_assertion_type\": \"urn:ietf:params:oauth:client-assertion-type:jwt-bearer\",
\"client_assertion\": \"$(cat /var/run/secrets/authaction/token)\",
\"subject_token\": \"USER_TOKEN\",
\"subject_token_type\": \"urn:ietf:params:oauth:token-type:access_token\",
\"audience\": [\"https://api.acme.eu\"]
}"

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.
  • Only agents may exchange tokens. An ordinary machine-to-machine client cannot act for a user, even with a valid secret.
  • Workload subjects match exactly. No wildcards, so a binding cannot widen to workloads you did not intend.
  • An unknown workload and a bad signature return the same response, so the endpoint cannot be used to enumerate which workloads are registered.

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.
}
}