Skip to content

Token Exchange (RFC 8693) — Delegated Auth for AI Agents

The OAuth 2.0 Token Exchange grant (RFC 8693) lets a developer building an AI agent — Claude, GPT, a custom MCP server, an autonomous workflow — exchange a user’s currently-valid access token for a scoped-down, short-lived agent token. The agent token’s act claim identifies the agent client; its sub retains the original user’s identity.

This solves the OAuth “confused deputy” problem: every action an agent takes is auditable to BOTH the human user (sub) AND the agent (act.sub), and the blast radius of a leaked agent token is bounded by short TTL + reduced scope + no refresh ability.

  • An AI agent (LLM-driven assistant, autonomous workflow) needs to call your API on behalf of a user who has already authenticated.
  • You want a per-task token: narrowed scope, short TTL (≤ 15 min), no refresh — exactly what an agent should hold.
  • You need a single audit signal that “agent X did Y on behalf of user Z” — the dual-traceability via act.sub + sub provides it.
Concernclient_credentials (Story 17.2)Token Exchange (Story 23.2)
Identity in subService / M2M client_idOriginal user (immutable across exchange)
Actor identitynoneact.sub = agent client_id
Authorization modelService permissions (m2m=true)User’s permissions, narrowed by scope
MAU billingNot countedCounted against the user’s MAU
Use caseServer-to-server (no user)AI agent acting on behalf of a user

Agent clients require Pro tier or higher. Free-tier tenants cannot register clientType: 'agent'. Existing Free-tier tenants without agent clients are unaffected. Upgrade in the dashboard to enable AI agent flows.

EndpointPurpose
POST /oauth/token (grant_type=urn:ietf:params:oauth:grant-type:token-exchange)Issue an agent token

The endpoint accepts application/x-www-form-urlencoded and follows the RFC 6749 §5.2 error envelope.

ParameterRequiredNotes
grant_typeyesExact value urn:ietf:params:oauth:grant-type:token-exchange
subject_tokenyesThe user’s currently-valid Rakomi access token (RS256 JWT)
subject_token_typeyesOnly urn:ietf:params:oauth:token-type:access_token accepted
requested_token_typeoptionalOnly urn:ietf:params:oauth:token-type:access_token accepted; defaults to that value if omitted
scopeoptionalSpace-delimited scopes; defaults to subject_token’s scope. The granted scope is the intersection of (requested) ∩ (subject_token) ∩ (agent client allowlist)
audienceoptionalTarget service URI; copied into the agent token’s aud claim
client_id + client_secretyesAgent client credentials. Body OR HTTP Basic per RFC 6749 §2.3.1
resourcerejectReserved — returns invalid_target (defer to a follow-up story)
actor_token, actor_token_typerejectOut-of-scope — actor identity is derived from the authenticated client_id
{
"access_token": "eyJhbGciOiJSUzI1NiIs...",
"issued_token_type": "urn:ietf:params:oauth:token-type:access_token",
"token_type": "Bearer",
"expires_in": 300,
"scope": "read"
}

There is no refresh_token and no id_token — agent tokens cannot be refreshed (bounded blast radius), and token-exchange is not a fresh authentication event.

SettingValue
Default TTL300s (5 minutes)
Max TTL (server-enforced cap)900s (15 minutes)
Per-agent_client_id rate limit60 / minute
Per-subject_token_jti re-use cap10 / minute (helps detect agent runaway loops)
Required clientTypeagent (extension of the existing `confidential
Required grantTypes['urn:ietf:params:oauth:grant-type:token-exchange'] (server-side validation: agent clients MAY ONLY enable this grant)
Auth methodclient_secret_basic (confidential-only — RFC 8693 §6)

Per-client TTL override: set agentTokenMaxTtlSec (60–900) on the agent client to narrow within the global cap.

Target latency: p50 ~15-20 ms, p99 < 100 ms under steady-state load (60 req/min/agent × 5 agents × 1 tenant). The mint cost dominates: RS256 sign + verify (~5 ms each on commodity hardware) plus 3-5 indexed DB queries (client lookup, session check, audit insert, outbox insert).

Forbidden subject_tokens (each rejected with invalid_grant and a generic error_description):

  • Tampered or invalid signature
  • Wrong issuer / audience / expiry
  • tenant_id claim mismatching the resolved tenant
  • M2M tokens (m2m: true)
  • Tokens already carrying an act claim (no chain delegation in 23.2)
  • Anonymous-user tokens (is_anonymous: true, Story 21.4)
  • Impersonated tokens (imp claim, Story F13)
  • Revoked sessions

Generic-error rule. The OUTBOUND error_description is generic ("Subject token invalid" or "Subject token revoked"). The specific reason (signature/iss/aud/exp/tenant/m2m/act/imp/anonymous/revoked) is recorded in the internal audit row + Pino structured log only.

No-chain delegation. Subject_tokens already carrying an act claim are rejected. RFC 8693 §1.3 leaves chain semantics to deployment policy; 23.2 chooses no-chain (confused-deputy mitigation). A future story may relax this with explicit per-tenant policy gates.

Server-side only. Agent clientSecret MUST run server-side. Never embed in browser or mobile code.

Every token-exchange flow event emits a structured audit row + Pino log line. Subscribers can detect agent-mediated user actions by filtering on agent_client_id in the oauth.token_exchange.issued webhook payload.

Event typeWhen
oauth.token_exchange.issuedSuccessful mint (audit + outbox webhook)
oauth.token_exchange.subject_invalidSubject token failed validation (reason in subject_invalid_reason)
oauth.token_exchange.scope_deniedScope intersection empty
oauth.token_exchange.rate_limitedPer-agent or per-subject-jti rate limit hit
oauth.token_exchange.client_unauthorizedConfidential gate, grant_types allowlist, or clientType discriminator failed
oauth.token_exchange.client_disabledDashboard “Disable agent client” action
oauth.token_exchange.revocation_requestedTenant explicitly requests a kill

The internal audit metadata schema includes subject_token_jti_hash_prefix (12-char SHA-256 hex) — never the raw token.

import { RakomiClient } from '@rakomi/node';
const client = new RakomiClient({
apiKey: process.env.RAKOMI_API_KEY!,
clientId: process.env.RAKOMI_AGENT_CLIENT_ID!, // agent-type client
clientSecret: process.env.RAKOMI_AGENT_CLIENT_SECRET!, // server-side only!
});
// userToken came from the human's normal sign-in flow.
const agentToken = await client.tokens.exchange({
subjectToken: userToken,
scope: ['read'], // narrowed scope; intersection enforced server-side
audience: 'https://api.your-service.com',
});
// agentToken.accessToken is a short-lived (5 min) JWT carrying:
// sub = original user's ID (audit traceability)
// act = { sub: '<agent_client_id>' }
// scope = 'read' (narrowed)
// amr = [...subject's amr, 'tx'] (delegation marker)
// Use it like any other Bearer token.
await fetch('https://api.your-service.com/data', {
headers: { Authorization: `Bearer ${agentToken.accessToken}` },
});
Terminal window
curl -X POST https://api.rakomi.com/oauth/token \
-u "$AGENT_CLIENT_ID:$AGENT_CLIENT_SECRET" \
-H 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode "subject_token=$USER_TOKEN" \
--data-urlencode 'subject_token_type=urn:ietf:params:oauth:token-type:access_token' \
--data-urlencode 'scope=read'
  1. The user signs in normally (any flow — passkey, password, social, magic link). The user-side app receives a Rakomi access token.
  2. The user-side app sends the access token to the agent backend.
  3. The agent backend calls client.tokens.exchange({ subjectToken }) with its registered agent clientId + clientSecret (server-side only).
  4. The agent backend uses the returned agent token (TTL ≤ 5 min) to call downstream APIs.
  5. When the agent token expires, the agent re-exchanges from the original user token (or asks the user-side app for a fresh user token if the user token has expired).

The Node SDK’s verifyToken() surfaces agent tokens automatically:

const result = await client.verifyToken(bearerToken);
if (result.ok) {
if (result.data.isAgentToken) {
// result.data.userId = the human user (immutable)
// result.data.agent = { clientId: '<agent_client_id>', scopes: [...] }
// Use auth.agent.clientId for audit / RBAC decisions (was this action
// taken by an agent on the user's behalf?).
}
}

Dashboard → OAuth clients → select the agent client → “Disable agent client”. This clears the client’s grantTypes and emits an oauth.token_exchange.client_disabled audit event.

In-flight tokens still expire by TTL (≤ 5 min) — agent tokens are stateless JWTs with no sid claim, so there is no session row to flip. This is the documented trade-off for the bounded-blast-radius design; short TTL bounds it.

Find every action an agent took on behalf of user X yesterday:

SELECT * FROM auth_events
WHERE event_type = 'oauth.token_exchange.issued'
AND actor_user_id = '<user-id>'
AND created_at >= NOW() - INTERVAL '1 day'
ORDER BY created_at DESC;

Filter by agent client:

SELECT * FROM auth_events
WHERE event_type = 'oauth.token_exchange.issued'
AND metadata->>'agent_client_id' = '<agent-client-id>'
ORDER BY created_at DESC;
  • GDPR Art. 22 (automated decision-making): agents may trigger Art. 22 if they make decisions with legal/significant effects. The platform position: agents act under user consent; per-action review is the customer’s responsibility (we provide the audit trail).
  • EU AI Act Art. 14 (human oversight): customers deploying high-risk AI systems MUST configure scope/audit policies to enable human oversight. Disable-agent-client + scope policy + per-action review provide the primitives.
  • EU CRA (2024/2847) Annex III Class I: the @rakomi/node SDK is a customer-shipped product. Findings on the SDK get a vulnerability record with 10-year retention (security/cra-vulnerability-records/).