Multi-Tenant RAG Tenant Isolation
Master multi-tenant RAG tenant isolation with proven strategies to keep data secure, prevent leakage, and scale confidently across all your customers.
RAG with Row Level Security is a pattern where documents, chunks, and embeddings live in Postgres tables with owner or tenant columns, and RLS policies decide which rows each user's query can see, so vector search only returns passages the caller may read. We run every Powabase project on its own Postgres with RLS and pgvector, and knowledge-base search takes its scope on each request.
Last reviewed: September 24, 2026
A language model repeats whatever retrieval hands it. If a search over a shared index returns a passage from another customer's contract, the model will quote it back, and no system prompt reliably stops that. So the permission check has to happen before the model sees any context, at the moment you pick which chunks to retrieve. You'll hit this in a few common setups. A multi-tenant SaaS keeps every customer's documents in one database and must never mix them. A workspace app lets users share some files with a team and keep others private. An internal assistant indexes HR, legal, and engineering documents that different staff are allowed to read. In every one of them, the retrieval query has to carry the caller's identity, and the database or your backend has to filter by it. Filtering the model's answer afterwards is too late, because the leaked text is already in the prompt. For the wider design choices, such as a project per tenant versus shared tables, see multi-tenant RAG tenant isolation.
Row Level Security is a Postgres feature that filters rows per role and per request. You turn it on per table with ALTER TABLE ... ENABLE ROW LEVEL SECURITY, then add policies with CREATE POLICY. A policy's USING expression decides which existing rows a query can see, update, or delete, and its WITH CHECK expression decides which new or changed rows are allowed. Postgres adds these expressions to every query on the table, so application code can't forget them. Two defaults matter. When RLS is enabled and a table has no policy, Postgres applies default deny: no rows are visible or writable. Policies are permissive by default and combine with OR, while restrictive policies combine with AND, which is how you layer a tenant check on top of other rules. One exception trips teams up. Superusers, roles with the BYPASSRLS attribute, and usually the table owner skip RLS entirely. In Supabase-style stacks, the auth.uid() and auth.jwt() helpers read the signed-in user's ID and token claims, so a policy such as owner_id = (select auth.uid()) ties each row to the person making the request.
Use two tables. A documents table holds one row per file with an owner_id or tenant_id column. A chunks table (Supabase's guide calls it document_sections) holds the text passages and their embeddings, with a document_id foreign key. The policy on chunks then lets a user select a chunk only if they can see its parent document: document_id IN (SELECT id FROM documents WHERE owner_id = (SELECT auth.uid())). For sharing, replace the owner check with a lookup in a document_permissions or team-membership table. For multi-tenant apps, copy tenant_id onto the chunks table as well, so the policy is a plain column comparison instead of a join, and index it. Read the tenant from the JWT, for example auth.jwt() -> 'app_metadata' ->> 'tenant_id'. Use app_metadata rather than user_metadata, because users can edit their own user_metadata. Ingestion needs the same care. The job that splits and embeds documents usually runs with elevated privileges, so it has to stamp the right owner and tenant on every chunk it writes.
Run the similarity search as the user, not as an admin. With PostgREST, the client sends the user's access token, the request runs as the authenticated role, and RLS filters chunks before any rows come back. The usual shape is a SQL function, such as match_document_sections(query_embedding, match_count), that orders chunks by distance and returns the top k. Postgres functions are SECURITY INVOKER by default, so policies apply to the caller inside the function. A SECURITY DEFINER function runs with its owner's rights and can skip those policies, so avoid it for retrieval. Plan for one pgvector behavior. With approximate indexes such as HNSW, filtering happens after the index scan, so a very selective filter, such as one small tenant in a large table, can return fewer than k rows. pgvector 0.8.0 added iterative index scans that keep scanning until enough rows pass the filter. Partial indexes or partitioning by tenant also help at scale. For how pgvector compares to separate vector stores on filtering, see what a vector database is.
Most permission leaks in RAG come from code paths that never go through the user's role. The service-role or secret key is the main one. It exists so your server can do admin work, and it bypasses RLS by design, so any server code that searches with it must add the tenant or owner filter itself, every time. The same is true of a direct database connection as the table owner, which is what migrations and background jobs usually use. A separate vector database doesn't know about your Postgres policies at all, so permissions have to be copied into its namespaces or metadata and kept in sync when documents are reshared or deleted (see Powabase vs Pinecone). Agent tools are the newest gap. An agent that queries the database through an admin connection sees everything that connection sees, so scope what you pass to it.
Postgres evaluates a policy for every candidate row, so a slow policy makes every search slow. Four habits keep it cheap. Index every column a policy reads, such as tenant_id, owner_id, and document_id. Wrap auth helpers in a subquery, (SELECT auth.uid()) instead of auth.uid(), so Postgres evaluates the value once per statement instead of once per row. Supabase's RLS performance guide shows large gains from this change alone. Prefer a direct column comparison over a join, since copying tenant_id onto the chunks table turns a join into an equality check that uses an index. And still pass the filter explicitly in your query, such as WHERE tenant_id = $1, even though the policy enforces it. That gives the planner an extra condition to use, while the policy stays as the safety net. Check real queries with EXPLAIN ANALYZE while signed in as a real user, because an admin connection skips the policy and hides its cost. At large scale, partitioning chunks by tenant keeps both the policy and the vector index small.
How Powabase does it
Every Powabase project gets its own Postgres with RLS, pgvector, and the same auth.uid() and auth.jwt() helpers, so the pattern above works as written on your own tables in public. For managed knowledge bases, your backend decides the scope. Keep a documents table in public that maps each Powabase source to its owner or tenant, protected by RLS. When a user asks a question, your server reads the sources that user may see under their own token, then calls POST /api/knowledge-bases/{id}/search with those source_ids and any filter_metadata. Knowledge-base search runs server-side with your service key, so the scope you pass is the scope that applies.
FAQ
Master multi-tenant RAG tenant isolation with proven strategies to keep data secure, prevent leakage, and scale confidently across all your customers.
An agentic RAG loop's real dependency is a governed RAG backend: pgvector, BM25, metadata, and state on one Postgres, not LangGraph glue.
Store agent memory in Postgres without a separate vector store. Learn how one database handles everything your AI agent needs to remember.