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.
When to use
Section titled “When to use”- 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+subprovides it.
How it differs from client_credentials
Section titled “How it differs from client_credentials”| Concern | client_credentials (Story 17.2) | Token Exchange (Story 23.2) |
|---|---|---|
Identity in sub | Service / M2M client_id | Original user (immutable across exchange) |
| Actor identity | none | act.sub = agent client_id |
| Authorization model | Service permissions (m2m=true) | User’s permissions, narrowed by scope |
| MAU billing | Not counted | Counted against the user’s MAU |
| Use case | Server-to-server (no user) | AI agent acting on behalf of a user |
Pricing tier
Section titled “Pricing tier”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.
Endpoint
Section titled “Endpoint”| Endpoint | Purpose |
|---|---|
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.
Request parameters
Section titled “Request parameters”| Parameter | Required | Notes |
|---|---|---|
grant_type | yes | Exact value urn:ietf:params:oauth:grant-type:token-exchange |
subject_token | yes | The user’s currently-valid Rakomi access token (RS256 JWT) |
subject_token_type | yes | Only urn:ietf:params:oauth:token-type:access_token accepted |
requested_token_type | optional | Only urn:ietf:params:oauth:token-type:access_token accepted; defaults to that value if omitted |
scope | optional | Space-delimited scopes; defaults to subject_token’s scope. The granted scope is the intersection of (requested) ∩ (subject_token) ∩ (agent client allowlist) |
audience | optional | Target service URI; copied into the agent token’s aud claim |
client_id + client_secret | yes | Agent client credentials. Body OR HTTP Basic per RFC 6749 §2.3.1 |
resource | reject | Reserved — returns invalid_target (defer to a follow-up story) |
actor_token, actor_token_type | reject | Out-of-scope — actor identity is derived from the authenticated client_id |
Response shape (RFC 8693 §2.2.1)
Section titled “Response shape (RFC 8693 §2.2.1)”{ "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.
Defaults
Section titled “Defaults”| Setting | Value |
|---|---|
| Default TTL | 300s (5 minutes) |
| Max TTL (server-enforced cap) | 900s (15 minutes) |
Per-agent_client_id rate limit | 60 / minute |
Per-subject_token_jti re-use cap | 10 / minute (helps detect agent runaway loops) |
Required clientType | agent (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 method | client_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.
Performance
Section titled “Performance”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).
Security model
Section titled “Security model”Forbidden subject_tokens (each rejected with invalid_grant and a generic
error_description):
- Tampered or invalid signature
- Wrong issuer / audience / expiry
tenant_idclaim mismatching the resolved tenant- M2M tokens (
m2m: true) - Tokens already carrying an
actclaim (no chain delegation in 23.2) - Anonymous-user tokens (
is_anonymous: true, Story 21.4) - Impersonated tokens (
impclaim, 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.
Audit + observability
Section titled “Audit + observability”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 type | When |
|---|---|
oauth.token_exchange.issued | Successful mint (audit + outbox webhook) |
oauth.token_exchange.subject_invalid | Subject token failed validation (reason in subject_invalid_reason) |
oauth.token_exchange.scope_denied | Scope intersection empty |
oauth.token_exchange.rate_limited | Per-agent or per-subject-jti rate limit hit |
oauth.token_exchange.client_unauthorized | Confidential gate, grant_types allowlist, or clientType discriminator failed |
oauth.token_exchange.client_disabled | Dashboard “Disable agent client” action |
oauth.token_exchange.revocation_requested | Tenant explicitly requests a kill |
The internal audit metadata schema includes subject_token_jti_hash_prefix
(12-char SHA-256 hex) — never the raw token.
Quick starts
Section titled “Quick starts”Node SDK (@rakomi/node)
Section titled “Node SDK (@rakomi/node)”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}` },});Raw HTTP (curl)
Section titled “Raw HTTP (curl)”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'Claude / GPT-style agent flow
Section titled “Claude / GPT-style agent flow”- The user signs in normally (any flow — passkey, password, social, magic link). The user-side app receives a Rakomi access token.
- The user-side app sends the access token to the agent backend.
- The agent backend calls
client.tokens.exchange({ subjectToken })with its registered agentclientId+clientSecret(server-side only). - The agent backend uses the returned agent token (TTL ≤ 5 min) to call downstream APIs.
- 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).
Recognising agent tokens server-side
Section titled “Recognising agent tokens server-side”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?). }}Disabling an agent client
Section titled “Disabling an agent client”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.
Auditing — example queries
Section titled “Auditing — example queries”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;Compliance notes
Section titled “Compliance notes”- 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/nodeSDK is a customer-shipped product. Findings on the SDK get a vulnerability record with 10-year retention (security/cra-vulnerability-records/).