The gap between the demo and production
In the tutorial version of RAG, every document in the index is fair game. You embed a folder, you run a similarity search, you stuff the top five chunks into the prompt, and the answer looks great.
In production, the folder is a company. It has HR files, board decks, customer contracts, incident reports and a wiki where half the pages are restricted to one team. The moment more than one person with different access can query the same index, RAG stops being a search problem and becomes an authorization problem.
The most common mistake I see is trying to solve that authorization problem in the prompt:
You may only use documents the user is permitted to see.
Do not reveal confidential information.
By the time the model reads that instruction, the confidential chunk is already in its context window. You are asking a probabilistic text generator to un-know something you just handed it. Sometimes it will comply. Sometimes a well-phrased follow-up, a summarization request or a prompt injection inside another document will get it out. Either way, the access decision has already been made, and it was "yes."
Here are the places this tends to break.
Failure 1: filtering after retrieval instead of during it
A pattern that looks reasonable:
chunks = vector_store.similarity_search(query, k=20)
allowed = [c for c in chunks if user_can_read(user, c.metadata["doc_id"])]
context = allowed[:5]
This is better than nothing, but it has two problems. First, if the filter has a bug or a missing branch, the fallback is "everything." Second, top-k is computed over documents the user can't see, so a restricted folder full of near matches can push every allowed result out of the window and leave the user with an empty or degraded answer.
Push the permission into the query itself so the store never returns what the user can't read:
chunks = vector_store.similarity_search(
query,
k=5,
filter={"acl_groups": {"$in": user.group_ids}},
)
Now the retriever is the enforcement point, and the safe default (no matching groups) is "nothing."
Failure 2: permissions frozen at index time
Illustrative scenario: an engineer moves from the payments team to the mobile team on Monday. The source system revokes their access to the payments design docs the same day. The vector index was built last Thursday, with the old ACL copied into each chunk's metadata, and it re-syncs weekly.
For four days, the RAG assistant still treats that engineer as a payments team member.
Indexing a document also means indexing its access rules, and those rules change on a different schedule than the content. You need a plan for ACL drift: sync permission changes faster than content changes, resolve group membership at query time rather than baking user IDs into chunks, or check the source system for sensitive collections before returning results.
Failure 3: chunks that lose their parent's ACL
A document gets split into 40 chunks. An attachment gets extracted and indexed separately. A nightly job generates summaries of long threads and indexes those too.
Every one of those derived artifacts needs to inherit the most restrictive permission of whatever it came from. A summary of three documents where one is restricted is a restricted summary. If your ingestion pipeline treats derived content as new, permission-free content, you have built a laundering step.
Failure 4: treating citations as proof of authorization
Citations are a good feature. They let users check the answer against the source. But a citation answers "where did this come from?", not "was this person allowed to receive it?"
An answer that cites a document the user can't open is not a broken link. It is a disclosure that has already happened, with a footnote.
How to actually test it
Unit tests on user_can_read() are necessary but not enough. The test that catches real leaks looks like this:
- Pick a query that touches restricted material.
- Run it as several roles: admin, team member, contractor, a user whose access was revoked yesterday.
- For each run, log and compare the retrieved chunk IDs that entered the model's context, not just the final answer text.
The final answer can look identical across roles while the context underneath it is different. A model that "chose not to mention" a restricted chunk still had it, and you should treat that as a failed test.
The reframe
RAG access control is just ordinary authorization, applied at the step where data moves from storage into a prompt. The prompt is where you describe the behaviour you want. Retrieval is where you enforce what's allowed.
How are you handling ACL changes in your RAG indexes today: query-time resolution, fast permission sync, or something else entirely?
Top comments (24)
Thanks for this post
Thanks Ben, appreciate you taking the time to read it!
'Don't reveal it' is an instruction, and an instruction is a request, not a control. I run agents all day and this is the same lesson in a different costume: anything that must not happen has to be made structurally impossible, or at least structurally hard — filtered at retrieval, blocked in the tool layer, absent from the context entirely. The prompt is the weakest layer to put a rule in, because it's the one layer the model can talk itself around. What survives is what the system enforces, not what the text requests. Your retrieval-layer framing is where the rule actually becomes a fact.
Exactly. I think the key distinction is that the prompt can guide behavior, but it can't undo a disclosure that already happened. Once restricted data is in context, you're relying on the model to respect a boundary the system itself failed to enforce. That’s a pretty fragile place to put security.
Exactly — and that asymmetry is why I moved the boundary out of the prompt entirely. Context is copyable and permanent for the session; an instruction is neither. The version that survived contact for me: the sensitive material never enters the context in the first place — the agent works with a reference, a handle, and only the step that legally needs the value ever resolves it, outside the conversation. Prompts are for judgement calls. Walls should be architecture.
I like that distinction between giving the agent the actual data and giving it a reference to the data. It takes the idea one step further: even if the agent gets manipulated, there's nothing sensitive in its context to expose. Keeping the resolution outside the conversation also gives you a much cleaner boundary to reason about. Definitely a stronger design.
'Even if the agent gets manipulated, there's nothing sensitive in its context to expose' — that's the formulation I'd put on the wall. Most security thinking asks how to make the agent resist manipulation. This asks how to make manipulation unrewarding, which is a much stronger position because it doesn't depend on the model behaving. Design for the compromise, not against it. The boundary you can reason about cleanly is the one that holds on the bad day, not the polite one.
"Design for the compromise, not against it" is a really good way to put it. We spend so much time trying to make models more robust against manipulation, but there's always going to be another way to influence their behavior. Removing access to the sensitive data in the first place changes the whole equation. Even a compromised agent shouldn't automatically mean compromised data.
'Even a compromised agent shouldn't automatically mean compromised data' — that's the test sentence I'd grade designs by. If a breach of the agent becomes a breach of the data by default, the architecture made one failure do two failures' worth of damage. Keeping those failures independent of each other is most of what defense in depth ever meant. Good thread — this clarified my own thinking.
That's a great way to frame defense in depth. The goal isn't necessarily to make agent compromise impossible, but to make sure one compromised layer doesn't bring everything else down with it. If the agent can be manipulated without gaining access to the underlying data, you've contained the blast radius. I think that's a much more realistic security goal than relying on the model to always do the right thing.
Filtering after retrieval is the mistake we keep repeating. In our RAG pilot the vector query itself was scoped by a tenant tag in the metadata, so the model never saw a chunk the user was not allowed to read. Per permission indexes cost more work than one prompt line but they survive a paraphrased follow-up.
Exactly. A paraphrased follow-up is a good example of why prompt-level restrictions are so fragile. Scoping the query itself means the boundary holds regardless of how the user phrases the question or how the model interprets the instructions. The extra work upfront is definitely worth it when security is involved.
Query time, in the tool rather than the retriever. Search is a tool on the MCP server that ranks only documents both the agent and the person it acts for may read, so rights are checked on every call and never copied into an index. The agent's own grant also caps the follow-up searches Gautam raised.
The audience sits in each document's front matter, which moves the risk rather than removing it. Tag a finance file
audience: alland the control is wrong for everyone at once, so a static check refuses any audience nobody is granted.Your per-role test is my gate. It reads the server's audit, never the answer. One run across six models: 954 of 954 returned documents within the person's rights.
ADR 21
That's a really interesting approach. I especially like that the permission check happens on every tool call rather than relying on a potentially stale copy in the index. And the static check for audience metadata is a nice extra safeguard. 954/954 is a solid result. Thanks for sharing the ADR, definitely worth a read.
Really good point. If restricted data reaches the model’s context, the permission check is already too late. Enforcing ACLs at the retrieval layer makes much more sense, especially when permissions can change over time. The point about testing retrieved chunks instead of only the final answer is particularly useful.
Thanks! The retrieved chunks are really the interesting part to test. Otherwise you can have two users getting seemingly identical answers while one of them was exposed to data they shouldn't even have had in context. That's the kind of issue that can easily go unnoticed in production.
One place I'd extend the role test: tool calls. In an agent style RAG setup the model often runs its own follow up searches, and those calls can go out with the service's identity instead of the user's, so the first retrieval is filtered and the second one isn't. Logging chunk IDs per role catches it only if the log covers every retrieval in the turn, not just the first.
In the setups you've reviewed, does the user's identity usually flow into the tool layer, or does it stop at the first query?
That's a great point. The tool layer is an easy place to accidentally lose the user's identity, especially when agents make follow-up calls through a shared service account. I'd treat every retrieval during the entire turn as part of the same authorization boundary. Otherwise the first query can be perfectly safe while the second one leaks everything.
One additional edge case worth addressing is permission inheritance across derived data. Chunk-level ACLs are important, but summaries, cached retrieval results, embeddings generated from restricted content, and conversation memory can all become secondary disclosure paths if they don't preserve the source permissions.
At IT Path Solutions, this is an important consideration when building production RAG systems. I'd treat authorization as part of the entire data lifecycle, not just the vector query: resolve current access at retrieval time, propagate the most restrictive ACL to derived artifacts, and invalidate cached results when permissions change.
The strongest test is exactly the one mentioned here: inspect the chunk IDs entering the model's context for each user role. A safe-looking final answer doesn't prove that restricted information was never exposed to the model.
Absolutely. The derived data part is especially easy to overlook because once content gets summarized, embedded or cached, people sometimes start treating it as a separate artifact with its own permissions. But the sensitivity doesn't disappear just because the representation changed. I like thinking about authorization across the entire data lifecycle rather than treating the vector query as the only enforcement point. That's where a lot of subtle leaks can happen.
Caching derived artifacts is where Failure 2 and Failure 3 collide in practice. Teams often push ACL filters into the vector query and still keep a separate semantic cache keyed only by prompt embedding or tenant ID, so a user whose group access dropped yesterday still hits Tuesday's cached answer. Keying the cache by sorted permission principals, or bypassing shared caches whenever restricted collections match, closes that path alongside query-time retrieval filtering.
Absolutely. Caching is another layer that can silently undo all the work done at retrieval time. Even with correct ACL filtering, a cached answer generated under an older permission set can still leak information. I'd also be careful with invalidation when group membership changes, because getting the cache key right doesn't help if stale entries remain accessible.
Query-time resolution for me, because any sync lag is a window where someone who just lost access still retrieves the chunk. Your top-k point is what I hadn't considered. A restricted folder full of near matches pushing every allowed result out means people who did nothing wrong quietly get worse answers, and nothing in the logs looks like a permissions problem.
Exactly, and that's the tricky part. We tend to think of authorization failures only as data leaks, but they can also affect availability and answer quality. A user getting incomplete results because inaccessible documents dominate the top-k is still a retrieval problem caused by permissions. Good reason to enforce filtering before ranking.