---
key: "rag-tenant-isolation"
title: "Keep retrieval inside the user’s data boundary"
url: "https://softwaresecurity.com/domains/ai-emerging/llm-security/rag-tenant-isolation/"
api: "https://softwaresecurity.com/api/v1/articles/rag-tenant-isolation.json"
domain: "ai-emerging"
topic: "AI security"
type: "Guide"
identifiers: ["CWE-863","A01:2025","LLM09:2026","LLM02:2026","V8.2.2","V8.4.1","V14.2.2"]
tags: ["Access control","Multi-tenancy","LLM application security"]
stages: ["design","build"]
roles: ["developer","appsec-engineer","platform-sre"]
license: "CC-BY-4.0"
license_url: "https://creativecommons.org/licenses/by/4.0/"
code_license: "MIT"
---

# Keep retrieval inside the user’s data boundary

Carry document permissions through ingestion, retrieval, caching and citations so a model never receives another tenant’s records.

Canonical URL: https://softwaresecurity.com/domains/ai-emerging/llm-security/rag-tenant-isolation/

## In short

Enforce access before retrieval results leave the data store. Put the authenticated tenant and the caller’s current ACL groups in the vector query itself, so other users’ chunks never reach the reranker, the prompt or a log. Treat chunks, embeddings, titles, citations and cached answers as copies of the source document with the same permissions. Include the policy version in cache keys so revocation takes effect at once, and reject documents that arrive without ACL metadata.

- Vector and Embedding Weaknesses is LLM09:2026; cross-tenant and cross-role retrieval is its most common access-control failure.
- Filtering the model’s answer is too late: once a forbidden chunk reaches a reranker, prompt or log, it has already crossed the boundary.
- Embeddings are not anonymization. The vec2text inversion attack recovered 92% of 32-token inputs exactly from their embeddings.
- Approximate vector indexes apply `WHERE` filters after the index scan; enable iterative scans or partition by tenant so filters do not silently drop allowed results.
- A missing ACL must deny, and a cache key must carry the policy version, not just the user ID.

## Recommended defaults

| Setting | Value | Note | Source |
| --- | --- | --- | --- |
| Where the filter runs | Tenant and ACL predicates in the same query as the vector ORDER BY | Never retrieve broadly and filter afterwards in application code or in the prompt. | OWASP RAG Security Cheat Sheet |
| Tenant isolation (pgvector) | One LIST partition or table per tenant | A shared approximate index lets one tenant’s vectors affect another tenant’s recall and speed. | pgvector README, Multitenancy |
| `hnsw.iterative_scan` | `strict_order` (off by default) | Without it, a filter matching 10% of rows returns about 4 of the default `ef_search` 40 candidates. | pgvector 0.8.0+ README, Filtering |
| `hnsw.max_scan_tuples` | `20000` (default); raise if filtered queries return fewer than k rows | — | pgvector README, Iterative scan options |
| Answer cache key | Tenant + caller’s groups + question + ACL version | Add the user ID when permissions are granted per user. | — |
| Document without ACL metadata | Reject or quarantine at ingestion; never index as public | — | — |

## Verify it

1. Plant sentinels in another tenant’s document and in a same-tenant restricted document; search reranker input, prompt, logs and citations for them.
2. Revoke a user’s access to a document, repeat the same question at once, and confirm neither the cache nor a citation still exposes it.
3. Ingest a document without ACL metadata and confirm it is rejected or quarantined, not indexed.
4. Run a filtered query on the largest tenant and confirm it still returns k allowed chunks.
5. Open each citation as the asking user and confirm the source permission check runs again.

## What you will learn

- Locate the earliest disclosure boundary in a retrieval pipeline
- Evaluate document ACL changes independently of tenant matching
- Detect a stale-answer cache that bypasses current authorization

Audience: Application, platform and software security engineers

Before you start:

- Trace document ingestion, indexing, retrieval, reranking and response construction.

Time: About 18 minutes: 6 to read and 12 for the exercise

## Retrieval changes who can see the data

Retrieval-augmented generation selects external material for a model’s context. If an over-broad search retrieves another tenant’s document, telling the model not to reveal it is too late: the confidentiality boundary has already been crossed. Retrieval authorization must be enforced before protected material reaches the model, reranker, prompt log or any external service.

Treat chunks, embeddings, titles, citations and cached answers as representations of the source document. A system can filter full text correctly while leaking a confidential title in search results. Embeddings are not anonymization: Morris et al. (2023) showed that vec2text recovers 92% of 32-token inputs exactly from their embeddings, including full names from clinical notes, so an index of opaque vectors still needs the source document’s access control. The OWASP Top 10 for LLM Applications 2026 groups these failures, plus poisoning of the corpus, as LLM09:2026 Vector and Embedding Weaknesses (/owasp-llm-top-10/2026/llm09/).

## Worked retrieval: the final answer is too late to filter

A North support user asks about renewal terms. The candidates are N1, a North policy open to support and finance; N2, a North finance-only forecast; N3, a North document whose ACL is missing; and S1, a South contract. Tenant matching excludes S1 but does not authorize N2. The retrieval contract requires both the authenticated tenant and the current source ACL before any document text reaches a reranker or model. A filter that only compares tenant names leaves same-tenant role separation unenforced.

Pipeline A sends all four candidates to an external reranker and filters afterward. Even if its final answer cites only N1, protected text has already crossed the stated boundary. Pipeline B, the code below, asks the index for the user’s tenant only, drops every chunk the current ACL does not permit, and only then reranks. The difference is observable by capturing synthetic sentinels at each stage, never real confidential documents.

Revocation introduces time. Suppose N1’s ACL changes after a cached answer was produced. A cache keyed only by tenant and question returns old content without a new permission decision, and adding the user ID to the key does not help when that same user loses access. The code includes the ACL store’s version in the key, so any permission change forces a fresh, authorized retrieval.

Citations, titles, snippets and logs are derived representations too. A system that hides body text but displays “South acquisition offer” in a forbidden citation still discloses information. Apply the same boundary to every representation and check access again when a user opens a citation. Missing ACL metadata must never default to permission.

```python
class AclStore:
    """Authoritative document permissions. The version changes on every ACL change."""

    def __init__(self, acls):
        self.acls = {
            doc: frozenset(roles) for doc, roles in acls.items() if roles is not None
        }
        self.version = 1

    def set_acl(self, doc_id, roles):
        self.acls[doc_id] = frozenset(roles)
        self.version += 1

    def permits(self, doc_id, roles):
        return bool(
            self.acls.get(doc_id, frozenset()) & roles
        )  # no ACL record, no access


def retrieve(user, question, index, acl, rerank, cache, k=5):
    # The policy version is part of the key: a revocation is never answered from cache.
    key = (user["tenant"], frozenset(user["roles"]), question, acl.version)
    if key in cache:
        return cache[key]
    candidates = [
        chunk
        for chunk in index.search(
            question, tenant=user["tenant"]
        )  # tenant filter inside the query
        if acl.permits(
            chunk["doc_id"], user["roles"]
        )  # current ACL, checked before reranking
    ]
    result = rerank(question, candidates)[:k]  # the reranker only sees authorized text
    cache[key] = result
    return result
```

## Preserve provenance and permissions

At ingestion, attach a stable source identifier, tenant, authoritative access policy reference, version and lifecycle state to each derived chunk. Reject or quarantine documents that arrive without ACL metadata. Make the ingester’s scope explicit: a connector with broad administrative access must not make everything it collects available to every application user.

Handle permission changes and deletion as supported workflows. Re-indexing, asynchronous events and caches create delay, so define the maximum permitted propagation time and whether a fresh source-policy check is required before context assembly. Ingestion is also where poisoned content enters: record who could write each source, and see /domains/ai-emerging/ml-security/training-data-poisoning/ for detecting and removing manipulated documents.

## Constrain every candidate and reuse path

Derive user and tenant identity from the authenticated request. Push both filters into the vector store query, so the database returns only rows the caller may read. With pgvector, the statement below keeps each tenant in its own partition, refuses rows without an ACL, and matches the caller’s groups with the array overlap operator.

Approximate indexes such as HNSW apply `WHERE` conditions after scanning the index, so a selective filter can return far fewer than the requested rows and look like a recall bug. pgvector 0.8.0 added iterative index scans, which keep scanning until enough rows match or `hnsw.max_scan_tuples` is reached. Managed vector databases offer the same choice as namespaces, partitions or metadata filters; check that the filter runs inside the search, not on the returned top k.

Include permission context in caches or use equivalent isolation. Revalidate citations when users open them, constrain retrieval tools and treat instructions embedded in retrieved content as untrusted. Correct retrieval authorization does not authorize actions requested by the document (see /domains/ai-emerging/llm-security/prompt-injection-boundaries/).

```sql
CREATE EXTENSION IF NOT EXISTS vector;

-- One partition per tenant, so no tenant's index holds another tenant's vectors.
CREATE TABLE chunks (
  tenant_id  text      NOT NULL,
  doc_id     text      NOT NULL,
  acl_groups text[]    NOT NULL CHECK (cardinality(acl_groups) > 0),  -- no ACL, no row
  body       text      NOT NULL,
  embedding  vector(3) NOT NULL
) PARTITION BY LIST (tenant_id);
CREATE TABLE chunks_north PARTITION OF chunks FOR VALUES IN ('north');
CREATE TABLE chunks_south PARTITION OF chunks FOR VALUES IN ('south');
CREATE INDEX ON chunks_north USING hnsw (embedding vector_cosine_ops);
CREATE INDEX ON chunks_south USING hnsw (embedding vector_cosine_ops);

-- $1 tenant and $2 groups come from the authenticated session, never from the prompt.
PREPARE nearest_allowed(text, text[], vector) AS
  SELECT doc_id, body
  FROM chunks
  WHERE tenant_id = $1
    AND acl_groups && $2  -- the caller holds at least one group on the chunk
  ORDER BY embedding <=> $3
  LIMIT 20;

-- Keep scanning the index when the filters remove candidates (pgvector 0.8.0 or later).
SET hnsw.iterative_scan = strict_order;
```

## Test the assembled context, not only the answer

Use synthetic sentinel strings to trace forbidden material through retrieved candidates, reranking inputs, model context, logs and citations. Test normal same-tenant access, other-tenant access, a same-tenant restricted document, revoked permissions, a document without an ACL and stale cached answers. A polite refusal in the visible answer is not a pass if the forbidden document reached a downstream model or log.

Check recall as well as exclusion: a filtered query on a large tenant should still return the requested number of allowed chunks. The RAG isolation lab at /labs/rag-isolation/ repairs a retriever that returns another tenant’s documents.

## Why post-filtering and opaque embeddings still leak

Filtering only the top results after broad retrieval can also produce misleading empty answers because unauthorized candidates crowded out permitted ones. Design the supported index filtering and authoritative checks together, then test recall for allowed documents as well as exclusion of forbidden ones.

An embedding or opaque chunk ID may not reveal readable text directly, but it still represents a protected source and can support inference or later lookup. Preserve source lineage through chunking, re-indexing and deletion so permissions are enforceable across every derivative.

## Check the result against concrete cases

Each row is a document in the North support example and whether it may reach the reranker and model. The rag-isolation lab on this page has you repair a retriever until the same cases pass.

| Case | Expected result | What it establishes |
| --- | --- | --- |
| N1 before revocation | May enter context | Required same-tenant access |
| N2 | Exclude before reranker | Tenant equality alone is insufficient |
| S1 | Exclude before reranker | Cross-tenant isolation |
| N3 | Exclude/hold until authoritative policy available | Missing metadata is not an allow |
| Cached N1 after support access revoked | Do not serve protected content | Revocation covers derived answers |

## Guided practice

Compare two supplied traces under the exact pre-reranker authorization requirement, then handle a permission change.

### Supplied material

```text
Documents: N1 tenant=North ACL={support,finance} text=SENTINEL_N1; N2 tenant=North ACL={finance} text=SENTINEL_N2; S1 tenant=South ACL={support} text=SENTINEL_S1; N3 tenant=North ACL=missing text=SENTINEL_N3.
User: North, role support. Contract: tenant matches AND current ACL permits before reranker or model.
Trace A reranker input=[N1,N2,S1,N3], model input=[N1], visible answer cites N1.
Trace B authorization permits=[N1], reranker input=[N1], model input=[N1].
At t=10 a cached answer based on N1 is stored.
At t=11 N1 ACL becomes {finance}. Contract requires revocation effective immediately for new responses.
At t=12 support asks the same question; cache returns N1 answer without a policy check.
```

### 1. Evaluate each candidate

Decide N1–N3 and S1 using both tenant and ACL fields.

Worked answer: Only N1 is permitted initially. N2 lacks support permission; S1 belongs to another tenant; N3 has no authoritative allow. None of the last three may enter the reranker.

### 2. Find the earliest breach

Explain why A fails although its visible answer looks correct.

Worked answer: A exposes N2, S1 and N3 to the reranker before authorization. Filtering the model input or citations afterward cannot undo that disclosure. B respects the stated ordering for these candidates.

### 3. Apply revocation to the cache

Decide the t=12 response and explain why a user-specific cache key alone is insufficient.

Worked answer: Do not serve the N1-derived content after support permission is removed. The same user can lose access, so identity in the key does not replace current authorization or an invalidation mechanism meeting the immediate contract.

### 4. Define observable evidence

List the stages where forbidden sentinels must be absent and one success control.

Worked answer: Check reranker payloads, model context, prompt logs, snippets, citations and cached responses for N2/S1/N3 and revoked N1. Before revocation, support should still obtain a correct N1 answer; after revocation, an authorized finance user should retain access.

Deliverable: A per-stage record of where each sentinel may appear, the corrected pipeline order and cache key, and the test that would show a regression.

Scope: All documents and traces are synthetic. The exercise covers one tenant pair and one revocation; it does not test embedding inversion or poisoned documents.

## Executable practice

- [RAG access control lab: Stop a retriever from returning another tenant’s documents](https://softwaresecurity.com/labs/rag-isolation/)
- [Enforce retrieval and tool boundaries across crashes: project ZIP](https://softwaresecurity.com/downloads/projects/agent-tool-boundaries-26b9a352bb160338.zip) — Python 3.11+ · standard library

Articles and teaching material: CC BY 4.0. Original executable code: MIT. Attribution: Software Security.

## Sources

- [OWASP RAG Security Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/RAG_Security_Cheat_Sheet.html)
- [OWASP Top 10 for LLM Applications 2026 (LLM09 Vector and Embedding Weaknesses)](https://genai.owasp.org/resource/owasp-genai-llm-top-10-2026/). 2026 edition (3 August 2026).
- [OWASP LLM08:2025 Vector and Embedding Weaknesses (2025 list; LLM09 in 2026)](https://genai.owasp.org/llmrisk/llm082025-vector-and-embedding-weaknesses/). 2025 edition.
- [pgvector README: filtering, multitenancy and iterative index scans](https://github.com/pgvector/pgvector)
- [Morris et al. (2023): Text embeddings reveal (almost) as much as text](https://arxiv.org/abs/2310.06816). arXiv, October 2023.
- [OWASP Authorization Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Authorization_Cheat_Sheet.html)