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.

The agent can also ask for itself, with its own key and no agent id: await agent.create_managed_secret_connect_session(template_slug="stripe-api-key", user_token=user_jwt). The session is always for the calling agent. The two differ in one way: when the user holds nothing that could be delegated to the agent, the App call raises ConnectConfigError with code no_eligible_grants and creates no session, while the agent’s call creates the session anyway and the Connect page tells the user why after they sign in, so an agent never learns what a user holds.

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.

Only the user can approve, so the server holding the session token — and the user’s JWT — cannot approve on their behalf. For a managed secret, the Connect page has the user sign in through the app’s identity provider in that browser before it offers Approve. The identity provider therefore needs its sign-in client configured (Approving Connect delegations); without it, create_managed_secret_connect_session() raises ConnectConfigError with code idp_signin_not_configured. For OAuth, connecting the account at the provider is the user’s proof; only approving on an account they already connected asks for the identity-provider sign-in, and without a sign-in client the session sends them through the provider instead. Deliver connect_url to the user’s own browser — a link or popup on the web, and the system browser (never an embedded WebView) in a native app.

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.