Example finding How it works Coverage PENTEST METHODOLOGY DOCS PRICING FAQ MCP CONTACT LOG IN SIGN UP →

RAG chatbot leaks other users' documents: how to fix it

FIX / RAG DATA LEAK / 9 MIN READ

A RAG chatbot leaks other users’ documents when the retrieval step searches every chunk and does not filter by owner, so take retrieval offline now, then add an owner filter that the database enforces. The language model does not choose which documents it reads. Your code does. If your code sends the closest chunks from all users to the model, the model repeats them to anyone who asks.

This guide shows the common causes, how to confirm the leak with two test accounts, and how to fix it in order of urgency. It also shows how to check the damage and how to test that the fix holds.

What does it mean when a RAG chatbot leaks documents?

A RAG chatbot answers a question in three steps. First, the app converts the question to an embedding. Second, the app searches a vector index for the chunks that are closest to the question. Third, the app puts those chunks in the prompt, and the model writes the answer.

The leak happens in step two. A vector search does not know about users. It returns the closest chunks in the whole index. If the index holds the documents of many users, the closest chunk can belong to any of them.

Vibe coding tools often create this problem. You ask for a “chat with your documents” feature. The tool creates one table for chunks and one function for similarity search. The function works in a demo with one user. The tool does not add an owner column or a policy, because the demo does not need them.

The model does not cause the leak, and the model cannot stop it. A line in the system prompt such as “Never show data of other users” does not remove the data from the context. The model has already received the text.

Why does the chatbot show another user’s data?

Use this list to find the cause in your code. A single app can have more than one cause.

Cause How it looks in the code
No owner filter The search function has no where owner_id = ... condition
Filter sent by the browser The browser sends user_id in the request body and the server trusts it
Server uses the admin key The route calls the database with the service_role key, which bypasses RLS
Function runs as its owner The search function is security definer, so it skips the policies of the caller
No owner on ingestion The upload code writes chunks without an owner value
Shared cache The app caches answers by the text of the question, not by user and question
Open chat history The app loads a conversation by an ID from the client without an owner check
Public source links The answer shows links to files in a public storage bucket

The cause in the third and fourth rows is hard to see. The table has a policy, but the policy does not apply. The Supabase RLS documentation states that the service_role key bypasses RLS. It also states that a security definer function skips RLS when its owner can skip it. On Supabase, the owner is usually postgres, which can.

How do I confirm the leak?

Use two accounts that you control. Do not test with real customer accounts.

  1. Create account A and account B.
  2. As account A, upload a short document. Write a unique sentence in it, for example “The canary phrase is purple-otter-4417.”
  3. Wait until the app finishes indexing the document.
  4. Log out. Log in as account B.
  5. Ask the chatbot: “What is the canary phrase?”
  6. If the chatbot gives the phrase, the leak is confirmed.

Also try the same question without a login. Many apps protect the page, but not the API route behind it. Send the request from a terminal:

curl -s https://your-app.example/api/chat \
  -H "Content-Type: application/json" \
  -d '{"message":"What is the canary phrase?"}'

If the route answers without a session, the leak is open to the whole internet. Replace the URL with the route that your chat uses. You can find the route in the Network tab of your browser.

If the chatbot does not give the phrase, do not stop. Try rephrased questions, such as “Summarize the most recent document.” A search returns the closest chunks, so a vague question can return a chunk of another user.

You can also test the database directly. Open the SQL editor and run the query as a normal user, not as an admin:

begin;
set local role authenticated;
set local request.jwt.claims = '{"sub":"00000000-0000-0000-0000-000000000000"}';
select count(*) from public.document_sections;
rollback;

Replace the sub value with the ID of account B. The count must be zero, or it must equal the number of chunks that account B owns. If the count includes the chunks of account A, the table has no working policy.

What do I do first? Fix it now, in order of urgency

Do the steps in this order. Steps 1 and 2 stop the leak. The other steps remove the cause.

Caution: Step 1 breaks the chat feature for all users. This is the correct result. A broken feature costs less than a data leak.

  1. Take retrieval offline. Disable the chat route, or make the route answer without retrieved text. Use a feature flag, remove the route from the deployment, or return a fixed message. Do this before you start to edit the code.
  2. Close the anonymous path. Make the chat route return 401 when the request has no valid session. Check the session on the server, not in the browser.
  3. Stop using the admin key for user requests. Search your code for service_role and for any database client that uses a secret key. A route that answers a user question must use the token of that user. See what to do about the service_role key.
  4. Add an owner column to the chunks table. Fill the column for the existing rows. If you cannot decide who owns a row, delete the row and index the document again.
  5. Turn on RLS and add a policy. See the SQL below.
  6. Change the search function. The function must run as the caller and must not accept an owner value from the client.
  7. Clear the answer cache and the chat history that you cannot trust. A cached answer can contain leaked text.
  8. Test with two accounts again. Repeat the canary test. Then turn on retrieval.

Add an owner column and an RLS policy

This example uses Supabase and pgvector. The Supabase guide for RAG with permissions shows the same idea with a join to a documents table. The example below stores the owner on each chunk, so the policy does not need a join.

alter table public.document_sections
  add column owner_id uuid references auth.users (id) default auth.uid();

alter table public.document_sections enable row level security;

create policy "own chunks select" on public.document_sections
  for select to authenticated
  using (owner_id = (select auth.uid()));

create policy "own chunks insert" on public.document_sections
  for insert to authenticated
  with check (owner_id = (select auth.uid()));

Make sure that no policy gives access to the anon role. Do not write using (true). A policy with using (true) shows all rows to everyone.

If you need a full explanation of policies, read the Supabase RLS guide. This article covers only the chunks table.

Make the search function run as the caller

A SQL function in Postgres runs with the rights of the caller by default. Write security invoker on the function so that the intent is clear and so that nobody changes it by mistake.

create or replace function public.match_sections(
  query_embedding extensions.vector(1536),
  match_count int default 5
)
returns table (id bigint, content text, similarity float)
language sql
security invoker
set search_path = public, extensions
as $$
  select s.id, s.content, 1 - (s.embedding <=> query_embedding) as similarity
  from public.document_sections s
  order by s.embedding <=> query_embedding
  limit least(match_count, 20);
$$;

The function has no owner parameter. The policy on the table removes the rows of other users. The caller cannot ask for another owner, because the function does not accept one.

Change the vector size 1536 to the size of your embedding model. Call the function from your server with a Supabase client that carries the token of the signed-in user:

const { data } = await supabase.rpc("match_sections", {
  query_embedding: embedding,
  match_count: 5,
});

The client in this code must use the anon key and the user token. A client that uses the service_role key ignores the policy.

If you use a different vector store

Other vector stores have no RLS. Use the access features of your store. Most stores let you attach metadata to each vector and filter on it. Set the filter on the server with the user ID from the verified session. Never take the filter value from the request body. Read the documentation of your store for the exact feature, and test with the canary phrase.

How do I check the damage after a RAG leak?

You must find out which users saw the text of other users. The data you have depends on what the app logged.

  1. Read the logs of the chat route. Open the logs of your host, for example Vercel or Supabase. Find the requests that the chat route received since the feature went live.
  2. Look for stored retrieval data. Some apps store the retrieved chunk IDs with each answer. For each answer, compare the owner of the chunks with the user who asked. Any mismatch is a leak.
  3. Check the stored messages. If the app stores chat messages, search the answers for text that belongs to other users. Search for email addresses, company names, and the titles of documents.
  4. Check the anonymous requests. Find requests to the chat route that had no session. Each of these requests could read data of any user.
  5. Check unusual traffic. Look for one client that sends many different questions in a short time. A person who wants to extract documents sends many queries.
  6. Check the cache and the history tables. Look for rows that have a user ID that does not match the session that created them.

If the logs do not hold retrieved chunk IDs, you cannot prove that nobody saw leaked text. In that case, assume that all users who asked a question since the launch could have seen data of other users.

Warning: If the leaked documents hold personal data, health data, or contracts, you may have a duty to tell the affected users or a regulator. The rules depend on your country and on the type of data. Ask a lawyer before you decide that no report is needed.

How do I stop the next RAG leak?

Use these controls so that retrieval can only return rows that the current user may read.

  1. Store an owner on every chunk. Write the owner when the app creates the chunk, not later. Use default auth.uid() or a server value from the session.
  2. Let the database enforce the filter. Use RLS or the access feature of your vector store. A filter in application code can disappear when a coding tool changes the file.
  3. Keep the admin key out of user routes. Use the admin key only in background jobs that you control, such as the indexing worker. Check each pull request for new uses of it.
  4. Never trust identifiers from the client. The server takes the user ID from the verified session. The server does not read it from the request body, the URL, or a hidden field.
  5. Key every cache by user. If you cache embeddings or answers, include the user ID in the cache key. A cache that uses only the question text shares answers between users.
  6. Protect the source files. If the answer links to the original file, store the file in a private bucket and use short-lived signed links.
  7. Log chunk IDs. Store the IDs of the retrieved chunks with each answer. Then you can check a leak in minutes, and not guess.
  8. Keep a two-account test. Add the canary test from this guide to your test suite or release checklist. Run it after every change to the chat, upload, or search code.

Retrieval also has an attack surface for writes. If users can upload documents that other users retrieve, an attacker can put instructions in a document. Read RAG poisoning and indirect prompt injection for this second problem. For a related class of bugs in CRUD routes, read BOLA in AI-generated CRUD.

When should I get help?

Get help in these cases:

  • The leaked documents contain personal data, financial data, or health data. Ask a security engineer and a lawyer to review the incident.
  • You use a hosted vector database and cannot find a way to filter by user. Ask the vendor for the multi-tenant method that they support.
  • The logs show that a stranger read documents on purpose. Keep the logs, rotate all keys, and ask for a security review.
  • You do not know who owns old rows in the chunks table. Delete the rows and index the documents again. If you cannot do that, get help.
  • The two-account test still fails after the fix. Check the route again for the admin key, a security definer function, and a cache.

After you finish the fix, repeat the canary test with a fresh account. Confirm that account B gets no answer from the document of account A.

Common questions

Can I fix the leak with a system prompt?
No. A prompt asks the model to behave, but it does not control which chunks enter the context. Remove the other user's chunks before the model sees them.
Does the leak come from the language model?
Almost never. The leak comes from the retrieval step. The model only repeats the text that your code puts in the prompt.
Do I need a separate vector database for each user?
No. One table with an owner column and a policy is enough for most apps. Use separate stores only when a contract or a law requires hard separation.
Does a chat history table have the same problem?
Yes. If the app loads messages by a conversation ID that the client sends, one user can read the conversation of another user. Apply the same owner check to that table.
How do I know which users saw leaked text?
Read the logs that store the retrieved chunk IDs for each answer. Compare the owner of each chunk with the user who asked. If you do not log chunk IDs, you cannot know, and you must assume that all answers are affected.

Test your chatbot access

A VibeEval scan calls your deployed chat and retrieval endpoints without a login and with a second account, and reports answers that contain data from another user.

14-day free trial · No credit card · Cancel anytime

SCAN YOUR APP →