When RAG Returns Another Tenant’s Documents
Permission and tenancy leaks in retrieval — how they happen, how to test for them, and why filters are not enough.
Your retriever returned chunks. They belong to another tenant — the answer cites documents the caller must never see. This page is about that exact failure: retrieved chunks carry a tenant_id that is not the caller’s. It is not the inverse case where a filter silently excludes the right document (that is wrong chunk, cause 5), and it is not within-tenant document ACL depth (see access control).
Before you redesign the index, log what came back. Record the query, the caller’s verified tenant, every retrieved chunk id, each chunk’s tenant_id metadata, and the filters the retriever claims it applied. Cross-tenant leakage is silent: no error fires, and the model answers confidently from foreign context. Reading the retrieval — not the chat reply — is where every diagnosis starts.
First, which of the five leak causes is it?
Cross-tenant RAG leakage has five recurring causes, and each has a different fix. Adding namespaces when the bug is an unkeyed semantic cache wastes a sprint; tightening a filter when the bug is permission lag leaves revoked users reading embeddings for days. Attack-vector writeups list many surfaces in order. The faster path is differential diagnosis: start from the one symptom, and run the single probe that rules each cause in or out before you change architecture.
| Cause | What you see | Detection (run this first) | Fix |
|---|---|---|---|
| 1 · Forgotten tenant filter | One code path omits tenant_id; foreign chunks appear only there | leak_rate = foreign_tenant / k on every entrypoint | Storage-layer isolation |
| 2 · Caller-controlled tenant scope | Body/header/LLM filter can set another tenant | auth=A, body claims B → any B chunk fails | Derive tenant from verified session |
| 3 · Retrieve-then-filter | ANN sees all tenants; filter runs after; k starves or foreign ids exist pre-filter | foreign_prefilter_rate > 0 | Filter-then-retrieve |
| 4 · Cache without tenant key | Tenant B gets Tenant A’s answer and citations | paraphrase hit returns A’s citations | Tenant-scoped cache keys |
| 5 · Permission lag / ACL loss | Revoked user still retrieves; or lossy ACL admits extra groups | revoke → query before next sync | Fail-closed sync + ACL fidelity |
Each row is a measurement you run on a seeded two-tenant corpus before you change production code. The sections below expand the symptom, the probe, the cause, and the fix.
A code path forgot the tenant filter
The most common production leak: tenant scope lives only in application code, so any path that constructs a vector query without the filter — a new feature, a refactor, a debug script against production, a background job without HTTP context — returns every tenant’s nearest neighbors. Barski (January 2026) frames it as being one missing WHERE clause from a breach; AppScale’s 2026 attack-vector taxonomy lists the same pattern as cross-tenant retrieval leakage when filters are application-enforced only.
Symptom
Most chat paths look fine. One internal tool, batch reindex, or “similarity_search(query, k)” helper returns chunks whose tenant_id is not the caller’s.
Detection
Seed tenant acme and tenant globex with distinct marker strings. Call every retrieve entrypoint authenticated as acme. Measure leak_rate = count(chunk.tenant_id != “acme”) / k. Any value above 0 fails the gate.
Cause
Isolation depends on every call site remembering the filter. That is an honor system, not an architecture — Swaraj Patil (January 2026) calls it the application-layer filter trap.
Fix
Move the tenant boundary to the storage layer — per-tenant namespaces, collections, or database RLS — so a forgotten filter cannot return foreign rows. How to choose silo vs pool vs namespace is the job of multi-tenancy; this page only needs the rule: the credential must be unable to construct a cross-tenant query.
The tenant ID comes from the request body
A verified session says the caller is tenant A, but the retriever trusts a tenant value from the request body, a header, or an LLM-parsed filter expression. Preporato’s cross-tenant lab (2026) stacks two bugs that show up in real systems: a caller-controlled tenant scope, and a metadata filter built by string concatenation — the filter analogue of SQL injection — so a crafted value breaks out of the tenant predicate.
Symptom
Authenticated as Globex, a request that names Initech in the body (or injects into the filter string) returns Initech’s contract chunks.
Detection
Authenticate as A. Send a body or filter that claims tenant B. Assert every returned chunk’s tenant_id == A. Folarin’s isolation test (2026) is exactly this spoof: claims from the token, lie in the body, body must be ignored.
Cause
Tenant scope is taken from untrusted input, or the filter expression is concatenated rather than parameterized, so the storage predicate can be rewritten.
Fix
Derive tenant from the verified session or token on the server. Allow-list tenant values. Build filters with structured parameters, never string concat. If an LLM is allowed to construct the filter, treat that as a prompt-injection surface — see prompt injection.
You filter after retrieval instead of before
Retrieve-then-filter runs ANN across the shared index first, then drops unauthorized hits. Unauthorized vectors have already entered the candidate set. Mixpeek (tenant isolation guide) notes post-filtering can return fewer than the requested k results; perfecXion (March 2026) adds that starvation and timing differences can become existence side channels even when the final answer looks filtered.
Symptom
Final answers usually look tenant-clean, but logs show foreign tenant_id values in the pre-filter candidate list, or top-k collapses after filtering.
Detection
Log candidate chunk tenant ids before the post-filter and again after. Measure foreign_prefilter_rate. Any foreign id pre-filter means you are post-filtering on a shared ANN pass.
Cause
Authorization is applied after similarity search, so the index treats every tenant’s vectors as legal neighbors.
Fix
Filter-then-retrieve: constrain the query at the storage layer so unauthorized vectors never enter the candidate set. Pre-filter vs post-filter mechanics live on metadata filtering; tenant-scoped indexes and namespaces live on multi-tenancy.
The response cache is not keyed by tenant
Semantic response caches key on query embedding similarity. Without a tenant in the cache key, Tenant B’s paraphrase can hit Tenant A’s cached answer — including citations — and bypass every retrieval ACL. perfecXion (March 2026) measured a two-tenant deployment with 50 cached queries per tenant: at similarity threshold 0.70 the overall cache hit rate was 24.0% with 6.0% sensitive exposure; at 0.80 the overall hit rate was 4.0% with 0.0% sensitive exposure. Verify those figures on your own cache before you treat the thresholds as policy — the structural fix does not depend on the exact percentages.
Symptom
Tenant B receives Tenant A’s financial answer (and document citations) for a paraphrased question, with sub-100 ms latency that never touched retrieval.
Detection
As tenant A, ask a distinctive question and let the cache store the answer. As tenant B, ask a paraphrase. If B receives A’s citations or marker strings, the cache key omits tenant scope.
Cause
Cache lookup matches on embedding similarity alone. AppScale Vector 7 and Barski’s semantic-cache failure mode are the same channel.
Fix
Include tenant_id in the cache key (Folarin: the tenant must be in the key or you build a cross-tenant leak). Prefer hashing tenant plus permission-set so two users in the same tenant with different ACLs do not share entries. Invalidate on permission change; TTL alone is not enough when revocations arrive mid-TTL.
Permissions changed but the index did not
Source systems revoke access immediately; the vector index only knows the ACL metadata from the last sync. perfecXion (March 2026) describes permission lag across SharePoint, Confluence, and Google Drive integrations where sync intervals are measured in minutes to days, and separately describes ACL translation loss: nested groups, negations, and expiring share links flatten poorly into key-value metadata, so the index admits principals the source system already excluded.
Symptom
HR revoked a departing employee in SharePoint an hour ago; the RAG chat still retrieves the confidential chunks. Or a user excluded by a source negation still passes the flattened allowed_groups filter.
Detection
Revoke a test principal in the source system. Query RAG before the next sync window. If chunks still return, you have lag. Separately diff source ACL vs ingested metadata on a document with negation or nested groups — any missing constraint is translation loss.
Cause
Authorization in the index is a stale or lossy copy of source authorization. Truto (2026) pairs the same class with deleted-record drift: source deletes leave orphaned embeddings.
Fix
Fail closed on sync errors; shorten sync; re-index on permission webhooks. Orphaned embeddings after deletes are also a stale index problem. Within-tenant document ACL design — ReBAC, policy engines, filter-then-retrieve at user scope — belongs on access control.
How do you prove isolation with one probe suite?
Rather than reason about the five causes one at a time, run them as one suite against a tiny in-memory stand-in for your retriever and cache. Given two seeded tenants and callable retrieve/cache hooks, this prints which leak cause fired. Swap the stubs for your real store — Weaviate multi-tenancy tenants, Pinecone namespaces, Qdrant collections, or pgvector RLS — without changing the assertions.
Before you trust the numbers
These probes are boolean gates on a seeded corpus, not estimates of production leak rate. Run them in CI on every deploy. Isolation bugs do not announce themselves; they wait for the worst possible moment (Folarin, 2026).
"""Cross-tenant leakage probes — five causes, one suite (stdlib only).
Wire retrieve() / cache_get() / cache_set() to your real stack before CI.
Each chunk is a dict: {"id": str, "tenant_id": str, "text": str}.
"""
def leak_rate(chunks, caller):
if not chunks:
return 0.0
foreign = sum(1 for c in chunks if c["tenant_id"] != caller)
return foreign / len(chunks)
def probe_tenant_leakage(*, retrieve, cache_get, cache_set, k=5):
"""Print which of the five leakage causes fired.
retrieve(caller, query, *, tenant_from_body=None, apply_filter=True,
return_prefilter=False) -> list[chunk] | (pre, post)
cache_get(caller, query) / cache_set(caller, query, answer_chunks)
"""
# Cause 1 — forgotten filter: same query with filter forced off
normal = retrieve("acme", "secret roadmap", apply_filter=True)
open_ = retrieve("acme", "secret roadmap", apply_filter=False)
if leak_rate(open_, "acme") > 0 and leak_rate(normal, "acme") == 0:
print("CAUSE 1 forgotten filter -> move tenant scope to storage layer")
# Cause 2 — caller-controlled scope: auth=acme, body claims globex
spoof = retrieve("acme", "secret roadmap", tenant_from_body="globex")
if leak_rate(spoof, "acme") > 0:
print("CAUSE 2 caller-controlled tenant -> derive tenant from verified session")
# Cause 3 — retrieve-then-filter: foreign ids exist before post-filter
pre, post = retrieve("acme", "secret roadmap", return_prefilter=True)
if leak_rate(pre, "acme") > 0:
print(f"CAUSE 3 retrieve-then-filter "
f"(foreign_prefilter_rate={leak_rate(pre, 'acme'):.2f}) "
f"-> filter-then-retrieve")
# Cause 4 — cache without tenant key
acme_ans = [{"id": "a1", "tenant_id": "acme", "text": "acme Q3 revenue MARKER"}]
cache_set("acme", "what is our Q3 revenue?", acme_ans)
hit = cache_get("globex", "what was the company's third quarter revenue?")
if hit and any("MARKER" in c.get("text", "") for c in hit):
print("CAUSE 4 cache cross-talk -> put tenant_id in the cache key")
# Cause 5 — permission lag: simulate revoked principal still retrieving
revoked = retrieve("revoked-user", "board minutes", apply_filter=True)
if any(c.get("acl_allows_revoked") for c in revoked):
print("CAUSE 5 permission lag / ACL loss -> fail-closed sync + ACL fidelity")
if leak_rate(normal, "acme") == 0 and leak_rate(spoof, "acme") == 0:
print("baseline retrieve as acme: PASS (no foreign tenant_id)")
The suite is deliberately transparent: each block is one row of the detection table above. Wire it to every retrieve entrypoint you ship — chat, tools, batch jobs, and admin search — because the forgotten-filter cause lives in the path you forgot to test.
What is cross-tenant RAG leakage?
Cross-tenant RAG leakage is when a retriever returns chunks whose tenant_id is not the caller's, and the model answers from that foreign context. It is silent: no error fires, and the chat still looks successful. It is distinct from a filter that excludes the right document, and distinct from within-tenant document ACL failures.
Is a metadata filter enough for multi-tenant RAG?
A metadata filter alone is not enough if it is enforced only in application code. Any path that omits the filter, trusts a caller-supplied tenant value, or post-filters after a shared ANN pass can leak. Prefer storage-layer isolation — namespaces, per-tenant collections, or database RLS — and keep the filter as defence in depth. How to choose the isolation model is covered on the multi-tenancy page.
How do I test that tenant isolation holds?
Seed two tenants with distinct marker documents. Authenticate as tenant A and assert that no returned chunk carries tenant B's id — including when the request body claims to be B. Also probe with the filter forced off, inspect pre-filter candidate ids, and check that a semantic cache keyed without tenant does not serve A's answer to B. Run the suite in CI on every deploy.
Why did another customer's document appear in my RAG answer?
Five causes dominate: a code path forgot the tenant filter, the tenant id came from untrusted request input, you filtered after retrieval instead of before, a response cache was not keyed by tenant, or permissions changed in the source system but not in the index. Run the discriminating probe for each before changing architecture.
Does semantic caching leak across tenants?
Yes, if the cache key omits tenant_id. A paraphrase from tenant B can hit tenant A's cached answer and citations without touching retrieval ACLs. perfecXion (March 2026) measured sensitive exposure at a 0.70 similarity threshold in a two-tenant setup; the structural fix is to include tenant (and preferably permission-set) in the key and invalidate on revocation.
Where should I put silo vs pool vs namespace decisions?
On the multi-tenancy page — that node owns isolation models and trade-offs across Weaviate, Pinecone, Qdrant, and pgvector. This failure page only diagnoses which leak fired and points there for the storage-layer fix. Document-level who-can-see-what inside a tenant belongs on access control.