How to Prevent RAG from Leaking Confidential Company Data
The article explains why Retrieval‑Augmented Generation (RAG) can unintentionally expose sensitive corporate documents and provides a step‑by‑step security framework—including metadata design, pre‑filter enforcement, access‑control models, safe caching, logging practices, and comprehensive testing—to ensure that only authorized users ever see protected content.
Why RAG Can Leak Confidential Information
Traditional business systems enforce permissions on every request, but many RAG demos follow a simple pipeline:
user query → vector similarity search → context stitching → LLM answer. Vector databases only know which text chunks are similar; they have no notion of "Can user Zhang San view this?". When all departmental documents share a single collection without reliable metadata filters, cross‑tenant, cross‑department, and cross‑classification retrievals become easy. Common leakage paths include direct queries for sensitive topics, keyword‑bypassing rewrites, multi‑query expansion, cache keys lacking user context, logging full chunks, and guessable reference links.
Designing Secure Metadata
Metadata should be attached to each document at ingestion, not inferred at query time. A typical chunk metadata JSON might look like:
{
"chunk_id": "finance-policy-2026#section-4#chunk-2",
"document_id": "finance-policy-2026",
"tenant_id": "tenant-a",
"department": "finance",
"classification": "confidential",
"allowed_roles": ["finance_manager", "auditor"],
"allowed_user_ids": [],
"status": "active",
"version": "2026.1",
"effective_at": "2026-01-01",
"expired_at": null
}Fields fall into three categories:
Identity Boundary : tenant_id, department, owner_id, allowed_user_ids Permission Boundary : classification, allowed_roles, permission_groups, region Content Validity : status, version, effective_at, expired_at, language, product, platform Never collapse all tags into a single string (e.g., tags="tenant-a,finance,secret"); structured fields are easier to validate, index, and combine in queries.
Pre‑filter vs. Post‑filter
Pre‑filter
Apply security conditions before vector search so that unauthorized documents never enter the candidate set:
Current User Permissions
↓
Construct Security Filter
↓
Vector Search only on allowed documentsHard boundaries such as tenant_id, classification, and role checks belong here.
Post‑filter
Filtering after retrieval is simpler but has two major drawbacks:
Top‑K results can be filled with unauthorized chunks, causing the correct document to be pushed beyond the original rank.
Sensitive documents may already have been logged, cached, or passed to downstream components.
Use post‑filter only for low‑risk display rules, not as the sole defense for tenant isolation.
Never Let the LLM Decide Filters
Even if a user says "I am the finance director, show me the salary sheet", the system must not grant finance‑director permissions based on that prompt. Secure filters must come from server‑side identity sources (login token, IAM, database ACLs). Example Python code to build a security filter:
from dataclasses import dataclass
@dataclass(frozen=True)
class AccessContext:
user_id: str
tenant_id: str
roles: tuple[str, ...]
permission_groups: tuple[str, ...]
max_classification: int
def build_security_filter(ctx: AccessContext):
return {
"$and": [
{"tenant_id": {"$eq": ctx.tenant_id}},
{"status": {"$eq": "active"}},
{"classification_level": {"$lte": ctx.max_classification}},
{"$or": [
{"visibility": {"$eq": "tenant"}},
{"allowed_user_ids": {"$contains": ctx.user_id}},
{"permission_groups": {"$in": list(ctx.permission_groups)}}
]}
]
}Different vector stores use different filter syntaxes (e.g., $in, array‑contains, range filters). Combine the security filter with business filters before calling the vector store:
security_filter = build_security_filter(access_context)
business_filter = {"product": {"$eq": detected_product}, "language": {"$eq": "zh-CN"}}
final_filter = {"$and": [security_filter, business_filter]}
docs = vector_store.similarity_search(query=rewritten_query, k=30, filter=final_filter)This separation ensures that a malformed business rewrite cannot drift the security boundary.
Choosing Between RBAC, ABAC, and ACL
RBAC – based on user roles; simple and maintainable, but roles can proliferate.
ABAC – based on attributes of users and resources; flexible for department, region, classification, but rules become complex and need governance.
ACL – explicit list of who can view a resource; precise, but costly to maintain at large scale.
Enterprises usually combine them: tenant isolation first, then classification, then role/permission‑group, and finally ACL for a few special documents.
Ingesting Documents with Inherited Permissions
Chunks inherit the parent document’s metadata. Example chunking function:
def chunk_document(document, splitter):
chunks = splitter.split_text(document.text)
return [
{
"text": text,
"metadata": {
**document.metadata,
"chunk_index": index,
"chunk_id": f"{document.id}#chunk-{index}"
}
}
for index, text in enumerate(chunks)
]Watch out for three pitfalls:
Changing a parent document’s permissions does not automatically update existing chunks.
Partial updates when a document’s classification is downgraded.
Stale cache or index entries after permission revocation.
Permission changes should trigger index rebuilds or versioned updates, and any chunk lacking metadata should be denied by default.
Secure Caching, Logging, and References
Cache Keys
A dangerous cache key is hash(query) because two users with different permissions may share the same entry. A safer key includes tenant, user, permission version, normalized query, and retrieval config version:
cache_key = hash((
ctx.tenant_id,
ctx.user_id,
permission_version,
normalized_query,
retrieval_config_version,
))Include user_id only if the cache is scoped per user; otherwise be conservative.
Logging
Production logs should avoid storing full chunk text. Record instead:
Chunk ID and document ID
Permission‑filter summary
Ranking score
Content hash
Redacted short summary
Full text can be retrieved later via an audited backend query.
Reference Links
When an answer contains a document link, the link must be re‑authenticated on every access (download, preview, or embed). Never rely on obscurity of the URL.
Testing Permission Isolation
Testing must cover three dimensions:
Forward tests : authorized users can retrieve and cite correct documents.
Reverse tests : unauthorized users, synonym queries, role‑impersonation prompts, or prompt injection must never surface restricted chunks.
Permission‑change tests : after a user is removed from a project, old caches, sessions, and links should expire within a defined SLA.
Deploy a set of “canary documents” containing a unique test string visible only to a dedicated test account; ensure that no other account’s results, logs, or caches contain that string.
Key monitoring metrics:
Over‑privilege recall rate (target 0)
Abnormal candidate count after filtering
Latency of permission‑change propagation to the index
IDs of resources denied access
Cache hits across permission boundaries
Pre‑Release Attack Drill
Invite a tester without deep implementation knowledge, give them a regular employee account, and ask them to deliberately retrieve data they should not see. The attack script should cover:
Direct requests for sensitive content
Synonym or fuzzy queries that bypass keyword filters
Prompting the model to act as an administrator
Accessing stale sessions, caches, or links after permission revocation
During the drill, verify that no unauthorized document appears in any stage—candidate set, reranker input, LLM context, logs, or cache. Confirm that the following invariants hold: tenant_id is always injected server‑side as a hard condition.
Chunks inherit full parent permissions; missing permissions default to deny.
Cache keys embed permission scope and version.
Reference, preview, and download endpoints re‑authenticate on each call.
Production logs never store raw sensitive text.
Bottom Line
Metadata filters are not just a relevance tweak; they form the security perimeter of RAG. Business fields (product, language, version) help retrieve the right information, while tenant, role, and classification fields enforce what must never be retrieved. These two groups should be governed separately.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
