GUIDE

How do you enforce access control in RAG with Row Level Security?

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

Why does RAG need access control?

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.

How does Row Level Security work in Postgres?

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.

How do you model documents and chunks for RLS?

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.

  • documents: id, tenant_id, owner_id, title, source
  • chunks: id, document_id, tenant_id, content, embedding vector
  • document_permissions (optional): document_id, user_id or team_id
  • Indexes on tenant_id, owner_id, and document_id, plus an HNSW index on embedding

How do you filter vector search by the caller's identity?

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.

What bypasses RLS in a RAG pipeline?

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.

  • Service-role or secret key calls: add the tenant filter in code
  • Owner or superuser connections from jobs and scripts
  • Ingestion workers that write chunks without the right tenant_id
  • External vector stores and caches that don't carry permissions
  • Agent tools running on an admin connection
  • SECURITY DEFINER functions used for search

How do you keep RLS fast for retrieval?

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

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.

  • Your own tables in `public` with RLS policies for ownership and tenancy
  • Knowledge-base search takes `source_ids` and `filter_metadata` on every request
  • Agent runs take `runtime_knowledge_bases` entries with `source_ids`, so each run searches only what that user may read
  • Or follow the documented cookbook recipe: keep document ownership in an RLS-protected `public.documents` table, have your backend search the knowledge base with only the caller's `source_ids`, then pass the results to the agent as `context_items`
  • Vector, BM25, hybrid, and tree search with reranking, over documents extracted and indexed on upload
  • Service key stays on your server; the browser only ever holds the anon key and the user's token

Where to enforce access in a RAG system

  • RLS on documents and chunks tables

    Where access is enforced:
    Postgres, on every query as the user's role
    Good fit:
    Per-user and per-tenant apps on one database
    Watch out for:
    Service-key and owner connections skip it; index policy columns
  • Filters added in server code

    Where access is enforced:
    Your backend
    Good fit:
    Server-side search with an admin key
    Watch out for:
    Every code path has to remember the filter
  • Namespace or index per tenant in a vector store

    Where access is enforced:
    The vector store's API
    Good fit:
    Large collections split cleanly by tenant
    Watch out for:
    Permissions copied from your app and kept in sync
  • Project or database per tenant

    Where access is enforced:
    Separate databases
    Good fit:
    B2B customers who want hard separation
    Watch out for:
    More projects to provision and migrate
  • Powabase knowledge-base search with scoped source_ids

    Where access is enforced:
    Your backend, from an RLS-protected ownership table
    Good fit:
    Managed RAG with extraction, hybrid search, and agents
    Watch out for:
    Keep the service key on the server

FAQ

Questions.

Yes. Put chunks and embeddings in Postgres tables with owner or tenant columns, enable RLS, and run similarity search as the signed-in user. Postgres then returns only chunks the user may read, before anything reaches the model. Server code that uses an admin key must filter by tenant itself.

Yes. The service-role or secret key is meant for trusted server code and bypasses every RLS policy. Never ship it to a browser or app. Any retrieval your server runs with it must add the owner or tenant filter in the query. Powabase's service key works the same way.

Add owner_id or tenant_id columns to the chunks table, enable RLS with a policy such as owner_id = (select auth.uid()), and call a SECURITY INVOKER search function through PostgREST with the user's token. For selective filters on HNSW indexes, use pgvector 0.8.0 iterative scans or partition by tenant.

From a claim the user can't change. Read it from the JWT's app_metadata, for example auth.jwt() -> 'app_metadata' ->> 'tenant_id', or look it up in a membership table keyed by auth.uid(). Don't trust user_metadata or a value the client sends in the request body.

It adds a check per candidate row, so keep policies simple. Index the policy columns, wrap auth.uid() in a subquery so it runs once per statement, and store tenant_id directly on the chunks table instead of joining. Measure with EXPLAIN ANALYZE as a real user.

Keep an RLS-protected table in public that maps each source to its owner or tenant. Your backend reads the sources that user may see under their token, then passes them as source_ids, plus any filter_metadata, to the knowledge-base search endpoint or to an agent run's runtime_knowledge_bases.

Not by default. A separate vector database doesn't see your Postgres permissions, so you copy them into namespaces or metadata and keep them in sync. Keeping chunks in the same Postgres as your users and documents lets one set of policies govern both.

Docs

Sources

  1. https://www.postgresql.org/docs/current/ddl-rowsecurity.html: Enabling RLS per table, default deny when no policy exists, permissive and restrictive policies, and superusers, BYPASSRLS roles, and table owners bypassing row security.
  2. https://www.postgresql.org/docs/current/sql-createpolicy.html: CREATE POLICY with USING and WITH CHECK expressions.
  3. https://supabase.com/docs/guides/ai/rag-with-permissions: RAG with RLS: documents and document_sections tables, a select policy tied to document ownership, and similarity search that respects the policies.
  4. https://supabase.com/docs/guides/database/postgres/row-level-security: auth.uid() and auth.jwt() in policies, app_metadata vs user_metadata, indexing policy columns, and wrapping functions in a select for performance.
  5. https://supabase.com/docs/guides/api/api-keys: Secret and service-role keys bypass Row Level Security and must stay on the server.
  6. https://github.com/pgvector/pgvector: Filtering applied after approximate index scans, iterative index scans in 0.8.0, and partial indexes and partitioning for filtered queries.
  7. https://docs.powabase.ai/concepts/rls-model: Powabase credentials and roles, auth.uid()/auth.jwt()/auth.role(), RLS off by default on new public tables, and the service role bypassing RLS.
  8. https://docs.powabase.ai/api-reference/knowledge-bases: Knowledge-base search accepts source_ids and filter_metadata per request.
  9. https://docs.powabase.ai/guides/baas-ai-cookbook: Recipe for per-user RAG: document ownership in an RLS-protected public.documents table, knowledge-base search scoped to the caller's source_ids, results passed to an agent as context_items.