Skip to content
Harness Engineering2026-09-1011 min read

RAG Access Control: Stop Leaks Before the Prompt Builds

RAG access control means filtering by permission before retrieval, not after the answer is written. A worked two-user case shows where checks belong.

Key takeaways

  • A private document must be absent from retrieved and reranked context, not merely absent from the final written answer.
  • Validate the request credential, then enforce caller-specific document permissions; possession of a bearer token does not grant access to every indexed document.
  • Caching retrieval or reranking results by query text alone can silently replay one user's authorized set to a different user.
  • Check the exact expected document IDs in fixtures and require real retrieval results to be a subset of current permissions; inspect actual prompt bytes too.
  • Access changes after indexing must override stale index metadata, or a revoked document keeps surfacing until the index is rebuilt.

Rod Rivera

Author

RAG Access Control: Stop Leaks Before the Prompt Builds

Rod's note — read with a pencil; the margins are for you.

The document that shouldn't have been there

Consider a hypothetical shared document assistant. Two people, call them Ana and Bo, share a company FAQ. Each of them also has one private document: Ana has a salary letter, Bo has a performance review draft. Bo asks the assistant, "What's in my recent review?" The retrieval pipeline embeds the query, pulls back the top twenty candidate passages by vector similarity, reranks them, and hands the top few to the model. The model writes an answer. Suppose, for a moment, that answer never mentions Ana's salary letter. Did the system behave correctly?

Not necessarily. The question that matters is not what the model chose to write about. It's what documents made it into the context window in the first place. If Ana's salary letter was among the passages retrieved and reranked, then handed to the model alongside Bo's own material, the boundary was already crossed the instant that passage entered the prompt. A well-behaved language model choosing not to repeat it back is not a permission check. The application has already failed its context-access rule.

This is the core distinction this article works through: relevance and authorization are two separate questions, answered by two separate mechanisms, and a retrieval-augmented generation (RAG) system that only checks the first one has no access control at all. RAG access control means constraining the candidate set to what the caller is allowed to see, before ranking, before reranking, before the prompt is assembled. Get that ordering wrong and everything downstream, including a clean-looking answer, is not evidence of safety.

Why relevance and authorization are different filters

A retrieval pipeline typically has three stages: embed the query into a vector, search an index for the nearest candidate passages, then rerank those candidates by a model that scores query-passage pairs more precisely than raw vector distance can. Each of those stages answers the question "how relevant is this passage to the query?" None of them, by default, answers "is this caller allowed to see this passage?"

The NVIDIA NeMo Retriever documentation describes reranking as exactly this: a distinct pipeline stage that rescores candidate passages for relevance, separate from the embedding step that produced them (NVIDIA: NeMo Retriever text reranking overview). Nothing in that description implies document-level permission checking; reranking optimizes for "which of these is the best match," not "which of these is this user allowed to have." If your access control lives only in the prompt instructions ("don't mention documents that aren't the user's"), you've asked a relevance-scoring stage to also be a security boundary, and it was never built for that job.

The MCP authorization specification describes validation of access tokens for a protected resource. That does not define which internal documents each caller may read. Establish request identity through the authentication boundary, then enforce document permissions. A shared service credential, in particular, must not be mistaken for Bo's individual identity. This article assumes trusted authenticated caller context is available to the retrieval function.

Where the filter has to sit

Apply current permissions at the retrieval boundary and recheck results before constructing downstream requests. Where the vector database supports a correctly enforced authorization filter, constrain the query there. A trusted search service may internally inspect index identifiers while locating permitted results; the important output boundary is that unauthorized passage text does not reach the caller's reranker or generation context.

Current permissions constrain passage retrieval before reranking

The application must know which component enforces the filter. Filtering an answer after generation is too late for a rule that forbids unauthorized text from entering model context. Filtering after a hosted reranker has received the passages is also too late for that reranker boundary. Keep any broader internal index work inside a service authorized to process those records.

Building the test, not just the filter

Suppose you build a small in-memory fixture to check this reasoning, the kind of toy example useful for teaching the mechanism, distinct from anything running against production storage or a real vector database with concurrent writers. Three documents: an FAQ entry both users may read, Ana's salary letter, and Bo's review draft. An allow-list dictionary maps each user to the document IDs they may see.

python
from dataclasses import dataclass

@dataclass
class Document:
    doc_id: str
    text: str

# Synthetic document text, never real personnel records.
documents = [
    Document("faq-1", "Reset your password from the settings page."),
    Document("salary-ana", "Synthetic Ana-only compensation marker."),
    Document("review-bo", "Synthetic Bo-only review marker."),
]
current_permissions = {
    "ana": {"faq-1", "salary-ana"},
    "bo": {"faq-1", "review-bo"},
}

def allowed_ids(user):
    if user is None:
        raise PermissionError("Authenticated caller required")
    return set(current_permissions.get(user, set()))

def retrieve_candidates(query, authorized_docs):
    # Mock search: all supplied documents are relevant to this fixture.
    return list(authorized_docs)

def filter_by_authorization(candidates, user):
    allowed = allowed_ids(user)
    return [doc for doc in candidates if doc.doc_id in allowed]

def build_context(query, user):
    authorized = filter_by_authorization(documents, user)
    candidates = retrieve_candidates(query, authorized)
    reranker_input = filter_by_authorization(candidates, user)
    reranked = list(reversed(reranker_input))  # mock relevance ordering
    return filter_by_authorization(reranked, user)

bo_context = build_context("What's in my recent review?", "bo")
assert {doc.doc_id for doc in bo_context} == {"faq-1", "review-bo"}
assert "salary-ana" not in {doc.doc_id for doc in bo_context}

The expected Bo context contains the shared FAQ and Bo's review, in the mock's reversed relevance order. Ana's document is excluded before the mock search and reranker. The permission map is separate from indexed text so a permission change can take effect without rewriting the documents.

This is an in-memory fixture with no embedding model, vector database or external API. It exercises the application boundaries using scripted search and ranking. A production test must capture what each real component actually receives; matching this helper's output does not establish that an adapter passes its filters correctly.

A passing local test is not a deployment guarantee

This fixture demonstrates the filtering logic in isolation. It does not demonstrate that a production retrieval service, cache, or reranker endpoint enforces the same boundary under concurrent load, real storage, or a shared cache key. Test the deployed path separately, with the same ID-based assertion, before trusting the mechanism in production.

The trap hiding in caching

A cache can bypass a correct filter if its caller assumes cached results are already authorized. For example, an authorized result cached for Ana under a query-only key must not be returned directly for Bo's identical query. A cache hit can safely supply candidates only if the current caller's permission check still runs before those passages leave the trusted retrieval service.

Partitioning by caller or tenant reduces cross-user reuse, but identity alone does not handle permission changes for the same person. Include an authorization revision in the cache identity where available, invalidate affected entries, and recheck current permissions at the context boundary. Also preserve document identity and version so a cached ID cannot silently refer to different text.

The two branches below converge on the same current-permission check. A cached result created for Ana may omit Bo's own relevant document even after Ana's private material is removed. That is a retrieval-quality problem: reuse of a restricted candidate set can lose recall. Partition the cache appropriately or perform fresh retrieval when the candidate set is unsuitable; permission filtering alone does not restore missing candidates.

Fresh and cached candidates both pass current caller authorization before their passages enter reranking or generation context.

Access can change after indexing. If a manager loses access to Bo's review, stale index metadata must not continue authorizing it. Use an authoritative permission source or a documented, enforced authorization-version mechanism. Define how quickly revocation takes effect and what happens when that source is unavailable; this example denies use rather than treating a failed permission lookup as unrestricted access.

The NVIDIA NIM course's retrieval lesson explains the embedding and reranking stages this check surrounds. Its relevance examples are useful for understanding the pipeline, but they do not establish that a particular vector database or reranker enforces your document permissions.

Running the acceptance check end to end

Build a deterministic three-document fixture

Use documents with obvious ownership, like the FAQ-plus-two-private-documents case above, so the correct answer to "was this authorized" never depends on judging model output.

Verify the negative control in the local fixture

Using only the synthetic documents above and local mocks, disable authorization and confirm the other user's marker reaches the captured context. Keep this control away from real private documents and external model endpoints.

Apply the authorization filter and inspect IDs at every stage

Check IDs at the authorized retrieval output, reranker input and final context. Also inspect the captured request text for the forbidden marker; an ID-only assertion cannot detect text attached to the wrong ID.

Repeat the check after a simulated cache hit

Serve a cached candidate list for one user's query, then run a different user's authorization filter against it before reranking, and check the ID sets again.

In this deterministic fixture, compare against the exact expected set. In real retrieval, relevant results may omit some permitted documents, so require the returned set to be a subset of the current allow-list and test relevance separately. Neither check can be replaced by reading the final prose for signs of leakage. An answer that happens not to mention the salary letter tells you the model chose not to repeat it. It does not tell you the letter was absent from what the model read.

Quick check — An assistant's final answer to Bo never mentions Ana's salary letter, but a debug log shows the letter's document ID was present in the reranked candidate list handed to the model. Did the authorization check pass?

Check the decision

What would show this filter is actually working

A claim this specific needs a way to be wrong. The filter fails its job if any of the following happen in a real test: the authorized retrieval output contains a document outside current permissions; the reranked ID set contains one after filtering was supposed to run; a cache hit returns a candidate list from a different user's session without the filter re-applied; or a document whose access was revoked after indexing still appears because the filter trusted stale index metadata instead of a live permission lookup. Each of those is a concrete, checkable condition, not a vague sense that something might be wrong. Building the fixture in this article and deliberately breaking the filter, by disabling step three above, is a useful way to see false negatives your test would need to catch before trusting the same shape in a live system.

None of this claims that any specific vector database, cache layer, or hosted reranking API enforces access control for you by default. The NeMo Retriever documentation describes reranking as a relevance-scoring stage, not an access-control layer, and nothing in the Model Context Protocol's authorization specification implies that a valid credential grants blanket access to every resource an application might expose. The filter has to be your application's own responsibility, applied at the boundary between "documents that exist" and "documents this caller may read," before anything gets reranked or handed to a model.

See the full retrieval pipeline this access-control layer sits in front of

Once the authorization filter is in place, the embeddings-and-reranking lesson in this course walks through the retrieval stages it protects, including why a hardcoded reranker model ID needs the same scheduled verification as any other pipeline dependency.

Ready to put an agent to work?

Join the Prof Rod newsletter for one educational lesson a week, with worked examples attached. It is free to register for and separate from the Zero Employee community.