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.

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.
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

Fix it in your stack

Tested fixes: the vulnerable code, the fix, a plausible fix that still fails, and the regression tests that tell them apart.

Learning goals and prerequisitesAbout 18 minutes: 6 to read and 12 for the exercise

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 practice

Filter by tenant and ACL inside the retrieval query

BrowserYour applicationModel serviceSupport user(North)Retrieval serviceVector indexReranker andmodelQuestion, with thesession’s tenant andgroups1Nearest in north WHEREgroups && {support}2N1 only2Recheck N1 againstthe current ACLversion3Context: N1 only4Answer citing N14Answer; citationaccess rechecked onopen4Trust boundaryUntrusted input1Step, described below
  1. Authenticated requestA North support user asks about renewal terms. The tenant and groups come from the session, never from the question or a document.
  2. 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.
  3. 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.
  4. Model contextOnly N1 reaches the reranker, the model, logs and caches. When the user opens a citation, the source permission is checked again.
Filtering the final answer is too late: once another tenant’s text reaches a reranker, prompt or log, the boundary has already been crossed. The filter belongs in the query.

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.

Fixedretrieval.pyPython
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/).

Fixedretrieval.sqlSQL
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.

Check the result against concrete cases
CaseExpected resultWhat it establishes
N1 before revocationMay enter contextRequired same-tenant access
N2Exclude before rerankerTenant equality alone is insufficient
S1Exclude before rerankerCross-tenant isolation
N3Exclude/hold until authoritative policy availableMissing metadata is not an allow
Cached N1 after support access revokedDo not serve protected contentRevocation covers derived answers

Guided practice

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

Supplied scenario material
Plain 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. 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. 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. 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. 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.

Your 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.

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.

Python 3.11+ · standard library · 60–90 min

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:

Shell
python3 demo.py
python3 -m unittest -v test_boundaries.py

25 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.

  1. OWASP RAG Security Cheat Sheet
  2. OWASP Top 10 for LLM Applications 2026 (LLM09 Vector and Embedding Weaknesses). 2026 edition (3 August 2026).
  3. OWASP LLM08:2025 Vector and Embedding Weaknesses (2025 list; LLM09 in 2026). 2025 edition.
  4. pgvector README: filtering, multitenancy and iterative index scans
  5. Morris et al. (2023): Text embeddings reveal (almost) as much as text. arXiv, October 2023.
  6. OWASP Authorization Cheat Sheet

Reference information

ASVS 5.0.0 requirements
v5.0.0-8.2.2 · v5.0.0-8.4.1 · v5.0.0-14.2.2

Download this guide as Markdown