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
WHEREfilters 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.
| 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 | — | — |
- Plant sentinels in another tenant’s document and in a same-tenant restricted document; search reranker input, prompt, logs and citations for them.
- 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.
- Ingest a document without ACL metadata and confirm it is rejected or quarantined, not indexed.
- Run a filtered query on the largest tenant and confirm it still returns k allowed chunks.
- Open each citation as the asking user and confirm the source permission check runs again.
- Applies to
- pgvector 0.8.0 or later for iterative index scans (example tested on 0.8.7 with PostgreSQL 17)
Multi-tenant retrieval systems. Access filters must apply before content reaches rerankers, prompts, logs or shared caches.
- Part of
- Learning path: Secure an AI application (step 2 of 11). Workflows: Fix a finding, Secure a feature, Review a design
Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.
Learning goals and prerequisites
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
Before you start
Application, platform and software security engineers
- Trace document ingestion, indexing, retrieval, reranking and response construction.
Refresh first: Enforce authorization for every object action
Go to the guided practiceFilter by tenant and ACL inside the retrieval query
- Authenticated requestA North support user asks about renewal terms. The tenant and groups come from the session, never from the question or a document.
- Filtered searchThe query runs in North’s partition with the caller’s groups as a predicate next to the vector ordering, so N2 (finance only), N3 (no ACL) and South’s S1 are never returned.
- Current-policy checkThe retrieval service rechecks each returned chunk against the ACL store’s current version, and keys any cached answer by tenant, groups, question and that version, so a revocation applies at once.
- Model contextOnly N1 reaches the reranker, the model, logs and caches. When the user opens a citation, the source permission is checked again.
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/).
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.
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 resultAt 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.
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/).
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;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.
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.
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 scenario material
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.
Expected result and reasoning
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.
Expected result and reasoning
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.
Expected result and reasoning
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.
Expected result and reasoning
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.
All documents and traces are synthetic. The exercise covers one tenant pair and one revocation; it does not test embedding inversion or poisoned documents.
Save a draft on this device to resume later. Automatic saving begins after you choose Save. Drafts are never sent by this application; use synthetic data.
No draft saved on this device yet.
Repair the code
RAG access control lab: Stop a retriever from returning another tenant’s documents
Run a local project
Download and unzip a starter with failing tests and a step-by-step README, edit the named exercise file, and run the tests until they pass. The reference solution is a separate download, so you can attempt the repair before you see it.
Enforce retrieval and tool boundaries across crashes
Repair a two-tenant retrieval application and SQLite approval/outbox workflow. Prevent cross-tenant context, bind exact approvals, revoke queued work and recover a committed side effect without duplication.
In the extracted folder, run:
python3 demo.py
python3 -m unittest -v test_boundaries.py25 regression tests · The starter intentionally fails some cases.
Opens in a dev container (VS Code Dev Containers or GitHub Codespaces) with a pinned image.
Real SQLite transactions and independent durable provider receipts; deterministic ranker and trusted test identities. No model, MCP implementation or external effects.
Sources & further reading
The standards, specifications and documentation behind the guidance above. Each is the evidence for a claim on this page; consult it for detail beyond the defaults and versions listed here.
- OWASP RAG Security Cheat Sheet
- OWASP Top 10 for LLM Applications 2026 (LLM09 Vector and Embedding Weaknesses). 2026 edition (3 August 2026).
- OWASP LLM08:2025 Vector and Embedding Weaknesses (2025 list; LLM09 in 2026). 2025 edition.
- pgvector README: filtering, multitenancy and iterative index scans
- Morris et al. (2023): Text embeddings reveal (almost) as much as text. arXiv, October 2023.
- OWASP Authorization Cheat Sheet
Reference information
- SDLC phase
- DesignDevelopment
- ASVS 5.0.0 requirements
- v5.0.0-8.2.2 · v5.0.0-8.4.1 · v5.0.0-14.2.2