← Back to Blog

Multi-Tenant RAG Tenant Isolation

15 min read
Hunter Zhao
Engineering

Master multi-tenant RAG tenant isolation with proven strategies to keep data secure, prevent leakage, and scale confidently across all your customers.

Most multi-tenant RAG demos work because there's only one tenant in the demo. The moment a second customer signs up, the shortcuts you took stitching Supabase to an AI service to a vector store turn into a data-leak surface, and none of the individual components will tell you it's happening.

This piece is about the gap between that working prototype and a system where tenant A can never, under any query, retrieve tenant B's vectors. That gap is the whole discipline of multi-tenant RAG tenant isolation, and it's bigger than most teams budget for.

Why Stitching Supabase, an AI Service, and a Vector Store Is a Production Problem

The demo that works and the tenant boundary it silently ignores

The canonical stack is easy to draw: Postgres for app state, an embedding model behind an API, a vector index somewhere else, an LLM at the end. Wire them together with tenant_id fields in each system and a filter on every query, and the happy path passes.

The problem is that "the happy path passes" is exactly what post-retrieval filtering security theater looks like. By the time your authorization layer removes unauthorized documents from a top-k list, the ANN search has already ranked them, the latency has already reflected them, and if the filter is applied after the LLM sees the context, the model has already read them. Tianpan's writeup on vector store access control patterns makes the same point about shared indexes.

This is not hypothetical in either direction. CVE-2025-48757 came out of a scan of 1,645 Lovable-generated apps that found 303 endpoints across 170 projects with inadequate RLS, readable by an unauthenticated caller holding nothing but the public anon key that ships in the browser bundle. That's the boundary never being written. The other failure is the boundary being written and then stepped around: General Analysis demonstrated a Supabase MCP server running under service_role that read a poisoned support ticket and dumped the integration_tokens table into a public thread. The RLS policies in that setup were correct. The agent just wasn't subject to them.

The three burdens: tenant isolation, identity propagation, cross-vendor consistency

Turning the demo into a product means carrying three burdens at once:

  1. Tenant isolation enforced in storage, not in application code, so a bug in one query path can't leak everything.
  2. Identity propagation across microservices so the caller's identity travels intact from browser to API gateway to AI service to vector store, without being replaced by a service credential somewhere in the middle.
  3. Cross-vendor consistency so the same tenant boundary holds in Postgres, in the vector store, in the embedding provider's logs, and in the LLM's context window.

The rest of this article is what each of those actually costs.

Enforcing Tenant Isolation with Supabase Row Level Security and pgvector

Why WHERE tenant_id = $1 is a filter, not a security boundary

A WHERE tenant_id = $1 clause is a correctness check that depends on every developer, every ORM, and every future refactor remembering to include it. That's a convention dressed up as a boundary. The real boundary lives one layer down, in the database role system, where the row simply isn't visible to the caller regardless of what SQL they write.

That's the point of pgvector multi-tenant isolation with Row Level Security: the policy applies before similarity computation, so a retrieval_service role without BYPASSRLS cannot access rows outside the active tenant regardless of how the query is constructed. The standard pgvector multi-tenant recipe is straightforward: add a tenant_id column, run alter table document_embeddings enable row level security, and create a policy that restricts access to the current tenant.

Fail-closed policies: FORCE ROW LEVEL SECURITY, split read/write, auth.uid() scoping

A production Supabase Row Level Security posture has a few non-negotiables. Every SELECT policy on a user-owned table must include a direct ownership check like user_id = auth.uid(); every UPDATE/INSERT needs both USING and WITH CHECK; every policy should specify an explicit TO authenticated role rather than defaulting; and every policy needs a denied-access test in CI that queries with a foreign JWT and asserts zero rows. Supabase's own RAG-with-permissions guide walks through the same pattern: grant SELECT to authenticated, enable RLS, and write the policy by hand.

FORCE ROW LEVEL SECURITY matters because the table owner otherwise bypasses policies. Split read and write policies matter because the failure modes are different. And if a policy reads a session variable, set it with SET LOCAL (or set_config(..., true)) inside an explicit transaction, so the value dies with the transaction instead of riding a pooled connection into someone else's request. A hands-on walkthrough of database-layer isolation across eight Supabase products is worth reading before you write your first policy; it's blunt about the failure modes.

Our own pitfalls guide leads with the one that catches teams most often: a single-user prototype policy (blanket authenticated SELECT) has to be tightened before you invite a second user, or any signed-in account can read every other account's agents. That's the exact class of pitfall that ships to production.

Post-retrieval filtering vs pre-filter ACL enforcement

After-the-fact filtering fails in three ways worth naming. The LLM has already seen the unauthorized content by the time you strip it. The top-k you return is short by however many rows you removed, so recall degrades silently for tenants whose neighbors happen to belong to someone else. And the sharpest attack: result count and latency are both functions of how many rows the caller may not see lie near her query vector, which lets an attacker map another tenant's embedding space without ever seeing a row.

Pre-filter ACL enforcement means the authorization predicate is evaluated before or during the ANN scan, not after. In pgvector, that's what RLS achieves when the index and the policy cooperate, and it's the difference between a real boundary and a filter you're hoping catches everything on the way out.

Vector Store Authorization Failure Modes

Shared vector index cross-tenant leakage

The distinguishing property of a shared ANN index is that vectors from every tenant sit in the same graph and are indistinguishable to similarity search until metadata is applied. The graph itself encodes cross-tenant proximity, which is what makes shared-index leakage a structural risk, not just a query-path bug. The "result count as side channel" attack above works even when the filter is technically correct.

The strongest topological answer is per-tenant partitioning: a namespace, a collection, or a physical shard per customer. It scales the way the arithmetic suggests. At low thousands of tenants, per-tenant partitioning is operationally manageable and gives the strongest guarantees. At very large tenant counts, the per-partition overhead stops paying for itself and you land back on discriminator-based isolation, which means the boundary has to be enforced somewhere other than the topology. Skopx's team runs per-tenant ChromaDB collections for exactly this reason, listing embedding index pollution as one of six named failure modes for multi-tenant AI.

Metadata filter injection and the ingest-time vs query-time gap

Even inside a properly partitioned store, the tenant ID has to be written at ingest and matched at query, and these are usually different code paths, written by different people, months apart. A missing predicate at one of fifty call sites, a raw SDK call that bypasses the sanctioned wrapper, a background job that reuses a service credential: any of these breaks isolation without triggering an alert. As Particula's silo/pool/bridge writeup puts it, the tenant identifier travels as a parameter through retrieval caches, rerankers, and agent tools; every hop is a place it can be wrong, and RAG adds hops that traditional apps don't have.

The mitigation is a canary test that runs on every deploy — a better filter won't save you. Seed two synthetic tenants with distinctive documents, query as tenant A with terms that only match tenant B's canaries, and assert zero hits across every retrieval entry point in the product.

Propagating User Identity Across Service Boundaries

Passing Supabase Auth JWT claims to the AI service and vector store

The RLS story only holds if the database sees the end user's identity, not a service account. That means the JWT, or claims derived from it, has to survive the trip from browser to API gateway to AI worker to vector query.

On Powabase, the JWT's sub claim is what auth.uid() returns in SQL, and the role claim selects the Postgres role PostgREST assumes for the request. The Anon Key is the routing credential at the gateway; the Authorization: Bearer <user-token> header is what the downstream service uses for role assignment. Confusing those two is one of the more common production mistakes, and service role key browser exposure — embedding the Service Role Key in browser JS — ends RLS on the spot.

JWT passthrough, internal JWT minting, and on-behalf-of token exchange

There are five recognized identity-propagation patterns for microservices, each on a trust-vs-complexity spectrum: JWT passthrough (cheapest, tightest coupling), internal JWT minting at the edge, opaque token introspection, serialized proto principal, and SPIFFE/Istio mTLS.

Passthrough is fine when every downstream service can validate the same signing key. It stops being fine when a third-party vector store enters the picture and you don't want to hand it a token minted by your identity provider. That's where token exchange comes in: the AI worker swaps the user JWT for a short-lived, Qdrant JWT collection-scoped token, or a namespace-scoped Pinecone API call.

Background jobs, where there is no caller to propagate

Nightly ingest, reindexing, scheduled summarization, and retry queues all run with no user in the loop and no JWT to forward, which is precisely why they end up being the last thing in the system still holding a service credential. The answer isn't to fake a caller. It's delegated identity: the job carries a short-lived token scoped to the one tenant and the one operation it was enqueued to perform, minted per work item rather than per worker, and the same RLS policies apply to it as to a live request. A worker processing tenant A's documents should be exactly as unable to read tenant B's as tenant A's own users are. If your background jobs are the only component running as service_role, that isn't an exception to your isolation model. That's where it breaks.

The Confused Deputy Problem in Agentic RAG

System credentials vs user-scoped credentials in retrieval tools

An agent calling a search_documents tool with a system credential is the confused deputy problem in RAG waiting to happen. The tool has the agent's full corpus access; the user asking the question does not. Unless the retrieval tool re-authorizes with the user's identity, the agent will happily surface documents the user was never entitled to see.

Fix it in the architecture: retrieval tools take a user-scoped credential, and the database enforces the scope. Anything else pushes authorization back into agent-prompt logic, which is not an authorization layer.

ConfusedPilot and prompt injection into the RAG pipeline

RAG changes the attack surface because the information lives in a database, not just in model weights, so a document written by user A can carry instructions that manipulate the response returned to user B. ConfusedPilot-class attacks show that shared corpora with weak ingest hygiene turn every writer into a potential prompt author for every reader. Skopx's writeup on context window contamination treats this as a first-class isolation surface distinct from database-layer tenancy, which is the right framing.

The mitigation stack is layered: tenant partitioning at ingest, provenance tags on every chunk, and treating retrieved context as untrusted input rather than authoritative content.

Cross-Vendor Consistency: pgvector vs Pinecone, Weaviate, and Qdrant

Silo, pool, and bridge isolation patterns

Three topologies show up repeatedly. Silo is one index or database per tenant: strongest isolation, worst per-tenant overhead. Pool is one shared index with a tenant discriminator: cheapest, weakest boundary. Bridge is pooled storage with per-tenant logical partitions (namespaces, collections, shards). Particula's silo/pool/bridge decision framework is a useful reference when you're picking between them.

Pinecone namespace isolation is the canonical bridge pattern. Qdrant exposes JWT collection-scoped tokens so the tokens themselves carry the boundary. Weaviate multi-tenancy shard isolation gives each tenant its own shard (with RBAC layered on top in v1.29). pgvector with Supabase Row Level Security is a pool that behaves like a bridge because the database enforces the discriminator.

Mapping tenant identity to namespaces, shards, and scoped tokens

Picking a pattern is the easy part. The engineering work is writing the mapping layer that translates one authenticated user into (a) an RLS session variable, (b) a namespace string, (c) a collection-scoped token, and (d) a metadata filter, all consistently, everywhere. Every one of those has to fail closed. Every one has a canary test.

This is why we co-locate retrieval, rerank, and the agent runtime inside a per-project isolated stack on Powabase. For workloads that outgrow a single project's vector table, we recommend splitting collections into separate projects for independent scaling, so the "cross-vendor mapping" for project-level isolation collapses into one boundary rather than four. Within a project, RLS handles per-user tenant isolation the normal Postgres way.

Infrastructure Traps That Break Isolation Silently

Connection pool contamination and session variable bleed

RLS often depends on session variables: SET LOCAL app.current_tenant = ... before each query. Under a connection pooler, "session" and "transaction" are not the same thing, and a SET without LOCAL that leaks between requests will hand one tenant's context to another tenant's query.

This one gets miscast as a Postgres version requirement, and it isn't. SET LOCAL has been transaction-scoped for well over a decade; no recent release changed anything here. The bleed risk is session-level SET under transaction-mode pooling, which is a pooler behavior, not a Postgres-version behavior. Use SET LOCAL (or set_config(..., true)) inside an explicit transaction, wrap every request in one, and audit for any code path that doesn't.

Embedding inversion, soft-deleted vectors, and GDPR erasure

Embeddings are not one-way. Vec2Text-style attacks recover source text from vectors, which means a "deleted" document whose vector still lives in a shared index is still disclosable. GDPR erasure has to physically remove the vector, not tombstone it, and in an HNSW graph, that means either a rebuild or careful use of the vendor's actual delete path, verified end-to-end.

HNSW and IVFFlat tuning so RLS filters don't wreck recall

The hidden cost of pre-filter ACL enforcement is that a highly selective predicate on an ANN index can collapse into a sequential scan or destroy recall. At very large vector counts, pgvector's index build and rebuild times start to compete with normal OLTP traffic, and HNSW indexes are memory-hungry; reducing m or ef_construction requires a full rebuild, not an in-place tune. IVFFlat is cheaper to build but degrades faster under filtered queries.

Two mitigations actually address this rather than describing it. The first is iterative index scans, added in pgvector 0.8.0: set hnsw.iterative_scan to strict_order or relaxed_order and the scan keeps pulling from the index until enough rows survive the filter, bounded by hnsw.max_scan_tuples. That is the direct fix for the RLS-starved top-k described earlier, where the policy silently eats your candidate list and the tenant just sees worse answers. The second is structural: per-tenant partitions, or partial HNSW indexes scoped with a WHERE clause, so an index only ever contains one tenant's rows. EDB's pgvector security guidance is worth reading on why you'd bother, and honest about the underlying limitation: an RLS-filtered nearest-neighbor query still traverses the index broadly and filters afterward. The policy is a real boundary, but it doesn't narrow the traversal itself.

The tuning question is: at your tenant-size distribution, does the RLS predicate leave enough candidates in each list or graph neighborhood to preserve recall? That's an empirical question with a per-tenant answer.

The Real Engineering Cost Behind a Working Prototype

The prototype is a weekend's work. Production is the following list, and each item is non-optional:

  • RLS policies on every tenant-owned table, with USING and WITH CHECK, split by operation, tested with foreign JWTs in CI.
  • Canary tenants and cross-tenant retrieval tests wired into every deploy.
  • Identity propagation from browser through gateway, AI worker, and vector store, with a chosen pattern (passthrough, minting, or exchange) and a scoped credential for every downstream call.
  • A retrieval tool contract that takes user-scoped credentials, not system ones, so agents can't act as confused deputies.
  • Vector-store partitioning that matches your tenant count: per-tenant namespaces while that stays operationally sane, RLS-style discriminators once it doesn't.
  • Pooler-safe session variable handling, verified deletion for GDPR, and ANN index tuning that survives selective filters.

None of this is exotic. All of it is work that has to be done once per stack, and re-done every time a vendor changes.

The assemble-your-own approach typically stitches 5–7 tools — vector database, agent framework, workflow engine, LLM gateway, auth, storage, database — each with its own auth model and its own isolation semantics. The tenant boundary has to be re-expressed in each one, consistently, forever.

Treat Isolation as Architecture from the Start

The teams that ship multi-tenant RAG tenant isolation safely don't treat it as a filter they add before launch. They treat it as the shape of the system: one auth model, one identity that reaches the storage layer, one place where the boundary is enforced and every other layer inherits it.

That's the shape a per-project isolated Powabase stack is built for, where retrieval and the agent runtime execute under the caller's identity (available as a per-project setting), so the boundary is enforced once at the storage layer instead of re-argued at every call site. If you're currently drawing the box-and-arrow diagram between Supabase, an embedding API, and a vector vendor, the honest budget for making that diagram tenant-safe is measured in months. Start with the canary test today: seed two synthetic tenants, query across the boundary, assert zero hits. If it fails, everything else is downstream of fixing that.

multi-tenant RAG tenant isolation

Share this article