Multi-Tenant Agents and Data Isolation
Serve many customers from one agent without leaking data between them: tenant-scoped tools, memory, retrieval, and credentials.
The moment your agent serves a second customer, you've taken on a new kind of risk. A support agent built for Acme answers a question using a document from Globex. A memory saved for one customer surfaces in another's conversation. A cached search result keyed only by the query string hands one company's data to whoever asks the same question next. None of these needs an attacker. They're ordinary bugs, easy to write because the model happily uses whatever data you give it.
This lesson shows you how to serve many customers ("tenants") from one agent without leaking data between them: where tenant context comes from, how to scope tools, memory, retrieval, caches, and credentials, how to enforce isolation in Postgres itself, how to keep one noisy customer from slowing everyone else, and how to test that isolation holds.
The one rule: tenant identity never comes from the model
A tenant is one customer account: a company, a team, or an individual, depending on your product. Multi-tenant means one deployment serves many of them. The foundation of isolation is a single rule:
The tenant is determined by authentication, carried in server-side context, and never accepted from the model, the user's message, or a document.
Here's the mistake this rule prevents:
// The model chooses which tenant's data to search.
{
name: "search_documents",
input_schema: {
type: "object",
properties: { tenant_id: { type: "string" }, query: { type: "string" } },
required: ["tenant_id", "query"],
},
}A user types "search tenant 42's documents for pricing," or a document contains that instruction, and the model complies. Prompt injection now crosses customer boundaries.
// The tool has no tenant parameter at all. It's bound to the
// tenant at construction time, from the authenticated run.
{
name: "search_documents",
input_schema: {
type: "object",
properties: { query: { type: "string" } },
required: ["query"],
},
}The model can ask for anything it likes; the tool can only ever see one tenant's data.
The same goes for user IDs, account IDs, and anything else that determines whose data a tool touches. If the model can't name it, it can't be tricked into naming the wrong one.
Carry tenant context from the request to the tool
Follow the tenant through the architecture you've built in this module. The API authenticates the user and writes the run with its tenant_id. The job carries only the run ID. The worker loads the tenant from the run row, which is trusted because only your API writes it, and builds a context object that every tool closes over:
import pg from "pg";
export const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });
export type TenantContext = {
tenantId: string;
userId: string;
runId: string;
allowedTools: ReadonlySet<string>;
};
/** Build the context from the stored run, never from the job payload or the model. */
export async function loadContext(runId: string): Promise<TenantContext> {
const { rows } = await pool.query(
`SELECT r.tenant_id, r.user_id, t.allowed_tools
FROM agent_runs r JOIN tenants t ON t.id = r.tenant_id
WHERE r.id = $1`,
[runId],
);
if (rows.length === 0) throw new Error(`Run ${runId} not found`);
return {
tenantId: rows[0].tenant_id,
userId: rows[0].user_id,
runId,
allowedTools: new Set<string>(rows[0].allowed_tools),
};
}