Skip to content

Guides

Build a Multi-User Server on Alter

Front Alter with your own hosted service — many users, per-user identity, the credential never in your process.

Most integrations start as a single script with one grant. A multi-user server is the next shape: one hosted service (an API, an MCP server, a worker pool) that acts on behalf of many end users, each under their own provider credential. This guide is the broker pattern — how to resolve the right user’s grant per request and call as them, with the credential staying inside Alter.

By the end you have a request handler that: reads identity from the request, resolves the grant that user delegated to your agent, and runs the provider call through proxy_request so the credential is injected server-side and never touches your process.

end user ──(their JWT)──▶ your server (holds the agent key) ──▶ Alter ──▶ provider
• your server holds NO provider credential; it reads user identity from the request
• it acts AS your agent, on behalf of that user:
agent.list_grants(end_user_token=<their jwt>) → the grant they delegated
agent.proxy_request(grant_id=…, user_token=<their jwt>, …)
• Alter injects the credential, runs the call, and audits agent_id + user_id

Two things make this safe and per-user:

  • User identity comes from the authenticated request, never a model-controlled argument. The end user’s JWT arrives through the framework’s authentication path. The agent key stays in the server’s secret configuration; never accept it from a public request. A caller — or a prompt injection — must not be able to pick whose credential runs.
  • The JWT is a lookup key, not authorization. A user only appears in list_grants(end_user_token=…) because they previously delegated a grant to your agent (see Prerequisite). Runtime resolves the grant from the (agent, user) pair.

Per-user identity means each user has their own credential and delegates it to your agent. For a managed secret that is a one-time, operator/App-side Connect step:

session = await app.create_managed_secret_connect_session(
template_slug="stripe-api-key",
delegated_agent_id=AGENT_ID,
user_token=user_jwt, # the consenting user's IDP JWT
)
# Send the user to session.connect_url to consent. Afterwards your agent can
# resolve their grant by their JWT.

For OAuth, the equivalent is create_connect_session(..., agent=<agent-id>). Either way, until a user has delegated, your agent resolves no grant for them — a clean “not delegated” error, not someone else’s credential.

Resolve the user’s grant from their JWT, then call as them. Pass both grant_id and user_token: the pair is what the backend authorizes and records on-behalf-of, so a grant can’t be exercised as the wrong user.

from alter_sdk import Agent, HttpMethod, PendingApproval
async def handle(agent: Agent, user_jwt: str, body: dict):
# 1. Resolve the grant THIS user delegated to THIS agent (scoped by their JWT).
# Skip terminal-status grants and page through if the user has many.
grant_id = None
offset = 0
while grant_id is None:
page = await agent.list_grants(end_user_token=user_jwt, status="active", offset=offset)
for g in page.grants:
# A user-DELEGATED managed-secret grant only. An agent-OWNED grant is
# returned for every user (it has no end-user dimension), so picking it
# would run one shared credential for all and defeat per-user identity.
if getattr(g, "grant_kind", None) != "managed_secret":
continue
if getattr(g, "access_via", None) != "ms_delegation":
continue
if g.managed_secret_slug.startswith("stripe"):
grant_id = g.grant_id
break
if grant_id or not page.has_more:
break
offset += len(page.grants)
if grant_id is None:
return {"error": "not_delegated"} # the user hasn't delegated — never a fallback
# 2. Call as them. The credential is injected in Alter's backend, not here.
result = await agent.proxy_request(
HttpMethod.POST, "https://api.stripe.com/v1/refunds",
grant_id=grant_id, user_token=user_jwt, json=body,
context={"tool": "refund"},
)
if isinstance(result, PendingApproval):
return {
"status": "approval_required",
"approval_id": str(result.approval_id),
"urls": [gate.approval_url for gate in result.gates],
}
return {"status_code": result.status_code, "body": result.body_json()}
import { Agent, HttpMethod, PendingApproval } from "@alter-ai/alter-sdk";
async function handle(agent: Agent, userJwt: string, body: Record<string, unknown>) {
// 1. Resolve the grant THIS user delegated to THIS agent (scoped by their JWT).
let grantId: string | undefined;
let offset = 0;
while (!grantId) {
const page = await agent.listGrants({ endUserToken: userJwt, status: "active", offset });
// A user-DELEGATED managed-secret grant only — an agent-owned grant would
// resolve for every user and defeat per-user identity.
const g = page.grants.find(
(x) =>
x.grantKind === "managed_secret" &&
x.accessVia === "ms_delegation" &&
x.managedSecretSlug.startsWith("stripe"),
);
if (g) grantId = g.grantId;
if (grantId || !page.hasMore) break;
offset += page.grants.length;
}
if (!grantId) return { error: "not_delegated" };
// 2. Call as them. The credential is injected in Alter's backend, not here.
const result = await agent.proxyRequest({
method: HttpMethod.POST, url: "https://api.stripe.com/v1/refunds",
grantId, userToken: userJwt, json: body, context: { tool: "refund" },
});
if (result instanceof PendingApproval) {
return {
status: "approval_required",
approvalId: result.approvalId,
urls: result.gates.map((gate) => gate.approvalUrl),
};
}
return { statusCode: result.statusCode, body: result.bodyJson() };
}
  • Reuse the client for the server’s configured agent key. Constructing an Agent sets up an HTTP client; create it during service startup and close it during shutdown. Do not construct clients from caller-supplied agent keys.
  • Resolve to user-DELEGATED grants only. list_grants(end_user_token=…) returns the agent’s own managed-secret grants too (they have no end-user dimension, so the JWT doesn’t filter them) — select only grants with access_via == "ms_delegation", or an agent-owned credential runs for every user and per-user identity collapses.
  • Resolve to "active" grants only, and treat multiple matches as ambiguous. Never let a revoked grant shadow a live one, and don’t silently pick the first of several — surface it and narrow by account/label. See list_grants.
  • Sanitize errors before returning them. Map the SDK’s typed exceptions to safe messages for the caller; don’t forward raw exception text.
  • Handle HITL. A grant with an approval policy returns a PendingApproval — surface every gate URL (N-of-N policies return more than one) and resolve out of band, never block the request.
  • The destination-host allowlist is enforced by Alter. Configure the hosts each managed secret may call; a call to an un-allowlisted host is blocked before injection.

Prove the property that makes this a multi-user server, not a shared one: two delegated users run under two different credentials, and one user’s grant is rejected when called as another. Run alter verify --runtime for the calls, and assert two users resolve to two different provider identities and that a cross-user call fails.

Report an issue with this page

Necessary

Required for sign-in, security, authorization, and remembering your choices.

Always active

Analytics

Helps us understand which product and documentation features are useful.

Performance diagnostics

Uses performance tracing and privacy-masked session replay to diagnose problems.

You can change these choices at any time from Cookie settings.