Multi-tenant MCP: isolation with row-level security

An agent authenticated as tenant_a queries an orders table of five rows; row-level security returns only the two rows belonging to that tenant

 

An MCP server connects to your database as one role over one connection string, so the model sees every row belonging to every customer. The fix is to bind the authenticated caller’s tenant to the session and let PostgreSQL row-level security decide what is visible, rather than asking each tool to remember. That is four lines of code and two traps that will silently disable the whole thing. This is what we learned building it.

The short answer

If your product has tenants, the question “which rows may this caller see?” already has an answer, and your application enforces it on every request. An AI agent asks the same question as nobody in particular.

Move the enforcement into the database. Bind the tenant to the session, write the policy once per table, and every tool you ever add is scoped whether its author remembered or not.

One connection, everybody’s rows

Every Postgres MCP server works the same way: a connection string, a pool, and tools that run queries. That is completely fine for a single-tenant internal tool, where there is only one customer’s data to see.

For a multi-tenant SaaS it is disqualifying. A tool that says SELECT * FROM ordersreturns every order in the system. Not the caller’s orders — all of them. Nothing errors, nothing warns, and the model happily summarises your entire customer base for whoever asked.

Two fixes that do not work

ApproachWhat happensSafe?
Filter in the tool handlerEvery tool must remember. One that forgets leaks silently.No
Tell the model its tenant in the promptThe model can be talked out of it, and it decides what to send.No
A separate database per tenantWorks, but you probably would have done it already.Sometimes
Row-level security, bound per requestThe database refuses. No tool can opt out of it.Yes

The first is the one most teams reach for, and it is correct right up until it is not. Filtering in the handler works while every handler remembers. Then someone adds the twelfth tool on a Friday, omits the WHERE clause, and the failure is silent: the tool returns more rows and nothing complains.

The second is worse, because it looks like security. Telling the model in its system prompt which tenant it is working for puts the boundary inside the thing you are trying to constrain. A model that can name its own tenant is not isolated from anything.

Bind the caller into the session

PostgreSQL can hold a value for the duration of a transaction, and a policy can read it. Set it from the caller’s authenticated identity, never from tool arguments:

await client.query("BEGIN");
await client.query(
  "SELECT set_config($1, $2, true)",
  ["app.tenant_id", tenantId]
);
// every query in here is now scoped by the policy below
await client.query("COMMIT");

And the policy, once per table:

ALTER TABLE orders ENABLE ROW LEVEL SECURITY;
ALTER TABLE orders FORCE  ROW LEVEL SECURITY;

CREATE POLICY tenant_isolation ON orders
  USING      (tenant_id = current_setting('app.tenant_id', true))
  WITH CHECK (tenant_id = current_setting('app.tenant_id', true));

That is the whole mechanism. A tool that forgets to filter now returns the right rows anyway, because the database is doing the filtering. A cross-tenant read by primary key returns zero rows rather than an error — which matters, because an error would confirm the row exists.

Note set_config() rather than SET LOCAL app.tenant_id = …. SET LOCAL does not accept bind parameters, so the naive version interpolates a tenant id straight into SQL. set_config is the function form and takes parameters properly.

SET LOCAL, and the leak that only appears under load

The third argument to set_config is is_local. Pass true and the value is scoped to the transaction and discarded on COMMIT. Pass false — or use plain SET — and it persists for the life of the connection.

With a connection pool, that is a cross-tenant data leak. The connection goes back to the pool still carrying app.tenant_id = 'tenant_b', and the next piece of code to check it out inherits it.

We wrote a test for this, then checked the test was worth having by introducing the bug on purpose. The interesting part: a test that runs two hundred interleaved calls alternating between tenants still passed with the leak in place. Every scoped call rebinds before it queries, so interleaving never observes a stale value. The only test that failed was the one that took a pooled connection and queried with no binding at all — which is exactly what any other code sharing that pool would do.

If you take one thing from this article: the obvious concurrency test does not catch this bug. Query on an unbound connection and assert you get nothing.

The superuser trap

This one cost us an afternoon and is the reason we now assert it at startup.

A PostgreSQL superuser, or any role with BYPASSRLS, ignores row-level security entirely — including on tables with FORCE ROW LEVEL SECURITY. The policies are still there. They simply do not apply.

Our first test suite ran as the local superuser, which is what a Homebrew Postgres gives you by default. All nine isolation tests passed while reading all five seeded rows as both tenants. A suite that cannot fail is worse than no suite, because it tells you the thing is safe.

-- the role your MCP server connects as
CREATE ROLE mcp_app LOGIN PASSWORD '…' NOSUPERUSER NOBYPASSRLS;
GRANT SELECT, INSERT, UPDATE, DELETE ON orders TO mcp_app;

-- check what you are actually connected as. Both must be false.
SELECT rolsuper, rolbypassrls FROM pg_roles WHERE rolname = current_user;

Assert this when your server boots. Failing to start is a better outcome than running with no isolation and no signal that anything is wrong.

USING covers reads. WITH CHECK covers writes

A policy with only a USING clause governs SELECT, UPDATE and DELETE. It says nothing about INSERT, which is governed by WITH CHECK.

Get this wrong and the boundary holds in one direction only: tenant A cannot read tenant B’s rows, but can happily insert a row labelled tenant_id = 'tenant_b' — writing into another customer’s account. Both clauses, every table.

Failing closed

The second argument to current_setting is missing_ok. With true, an unbound session matches no rows. Without it, the query raises an error.

Prefer the version that returns nothing. A bug in your auth wiring then shows up as “no data” rather than “all data”, and a route you forgot to wrap fails visibly and harmlessly instead of quietly serving everything.

Reads are the easy half

Row-level security scopes which rows a write may touch. It says nothing about whether the write should happen at all, and “the agent deleted the right customer’s data” is not a good outcome.

The pattern we default to is propose-then-confirm. A write tool does not execute; it returns the exact statement it wouldrun, plus a signed token. A separate confirm step — shown to a human through MCP’s elicitation, a Slack approval, or your own UI — commits that statement and only that statement.

Sign the statement, not just an identifier. If the token covers only an id, an approval for “refund 5” can be redeemed against “refund 5000”. Signing the SQL and its parameters makes the approved action and the executed action provably the same one.

On a field CRM we built for a staffing agency, this is how an assistant with twelve typed tools and sixteen AI surfaces finished with zero records written by a model — every write came from an authenticated session, and tenant isolation was 236 row-level security policies across 58 tables rather than a paragraph of instructions.

What this does not solve

Being clear about the edges is the difference between a boundary and a feeling of one.

  • It does not prove a human looked. A confirm step verifies a token, not a person. Wire confirm to fire automatically and you have removed the boundary while keeping the paperwork.
  • It does not write your policies. Row-level security is opt-in per table. A table you forgot is a table with no boundary.
  • It is not a SQL sandbox. Scoping what a query may see is not the same as controlling what a query may say. A tool that accepts model-authored SQL is a different problem.
  • A proxy in front of someone else’s MCP server cannot do this. It never touches that server’s database connection, so it cannot bind an identity where a policy would read it. It can gate writes and log calls; it cannot isolate tenants.

Frequently Asked Questions

Is it safe to give an AI model access to production data?

Only if the enforcement lives outside the model. Bind the authenticated caller's tenant to the database session and let PostgreSQL row-level security decide what is visible, so no tool — and no prompt — can widen the boundary. Keep writes behind a propose-then-confirm step so the model can suggest a change but not commit one.

Why not just filter by tenant_id in the tool handler?

Because it only holds while every handler remembers. Application-layer filtering is correct until someone adds the twelfth tool at 6pm and omits the WHERE clause, and the failure is silent — the tool returns more rows and nothing errors. Row-level security moves the check to a place no tool can skip.

Does row-level security apply to superusers?

No. A PostgreSQL superuser, or any role with the BYPASSRLS attribute, ignores row-level security entirely — including on tables with FORCE ROW LEVEL SECURITY. If your MCP server connects as one, your policies do nothing and everything appears to work. Connect as a role created NOSUPERUSER NOBYPASSRLS and assert it at startup.

What is the difference between SET and SET LOCAL for tenant binding?

SET LOCAL is scoped to the surrounding transaction and is discarded on COMMIT or ROLLBACK. Plain SET persists for the life of the connection, so with a connection pool the next unrelated checkout inherits the previous caller's tenant. That is a cross-tenant data leak that appears only under load and never in development.

Do I need both USING and WITH CHECK in the policy?

Yes. USING governs SELECT, UPDATE and DELETE; WITH CHECK governs INSERT. A policy with only USING correctly hides other tenants' rows on read while still allowing a tenant to insert a row labelled with someone else's tenant id.

Does this stop the model writing to the database?

Not by itself — row-level security scopes which rows a write may touch, not whether a write happens. Pair it with a propose-then-confirm boundary: the tool returns the exact statement it would run plus a signed token, and a separate confirm step commits only that statement. On one production build this is how a CRM assistant with twelve tools finished with zero records written by a model.

If you are putting an agent in front of multi-tenant data and want the boundary designed in rather than retrofitted, this is most of what our MCP server development work actually consists of.