← Back to blog
HIPAAFebruary 26, 202611 min read

Role-Based Patient Data Protection in Healthcare RAG Systems

Implement role-based PII control in healthcare RAG pipelines. Doctors, nurses, billing clerks, and researchers each see only the patient data their role permits, satisfying HIPAA minimum necessary requirements.

Healthcare organizations are building RAG systems for clinical decision support, patient question-and-answer portals, and administrative assistants. The promise is powerful — an LLM that can reason over medical records, lab results, and clinical notes to help staff make better decisions faster. But there is a fundamental tension: different staff roles need radically different levels of access to patient data. A physician needs to see patient names, diagnoses, and medications. A billing clerk needs names and insurance details but should never see clinical data. A researcher needs the same data fully de-identified.

HIPAA's “minimum necessary” rule (45 CFR § 164.502(b)) codifies this principle: covered entities must make reasonable efforts to limit PHI exposure to the minimum necessary for the intended purpose. When a RAG pipeline sends all retrieved context to the LLM without filtering, every user — regardless of their role — gets the same unfiltered view of patient data. That is a compliance failure waiting to happen.

This article shows how to implement role-based PII control in a healthcare RAG system using Blindfold. The same vector store, the same retrieval pipeline, and different tokenization policies applied at query time based on the user's role. The result is a system where a doctor, a nurse, a billing clerk, and a researcher can all ask the same question and each see only what their role permits.

The Problem: One RAG Pipeline, Many Access Levels

In a standard RAG pipeline, the retriever pulls the most relevant documents from a vector database and injects them into the LLM prompt as context. The LLM then synthesizes an answer from that context. The problem is straightforward: the LLM sees everything that was retrieved, regardless of who initiated the query.

Consider a hospital's clinical decision support system. A patient record in the vector database might contain:

  • Patient name: Sarah Chen
  • Date of birth: 1985-03-14
  • SSN: 412-55-6789
  • Diagnosis: Type 2 Diabetes Mellitus
  • Medication: Metformin 500mg twice daily
  • Insurance: BlueCross policy BC-2847193
  • Contact: sarah.chen@email.com, (555) 012-3456
  • Billing: $2,340 outstanding balance

When a billing clerk queries the system about outstanding balances, the RAG pipeline retrieves this record and sends everything to the LLM — including the clinical diagnosis and medication details that the billing clerk has no business seeing. When a researcher queries the system for diabetes treatment patterns, the LLM sees real patient names and SSNs even though the researcher only needs de-identified aggregate data.

HIPAA's minimum necessary rule requires that you limit PHI disclosure to what is reasonably necessary for the purpose. Building separate vector stores for each role is technically possible but impractical — it means duplicating your entire document corpus, maintaining multiple ingestion pipelines, and keeping everything synchronized as patient records are updated. The operational cost is enormous.

HIPAA 45 CFR § 164.502(b): “When using or disclosing protected health information or when requesting protected health information from another covered entity or business associate, a covered entity or business associate must make reasonable efforts to limit protected health information to the minimum necessary to accomplish the intended purpose of the use, disclosure, or request.”

The Solution: Policy-Based Tokenization at Query Time

The key insight is that you do not need separate vector stores. You need a single retrieval pipeline with a policy layer that controls what the LLM sees based on who is asking. Blindfold's entities parameter lets you specify exactly which entity types to tokenize in a given call. By mapping each role to a specific set of entities, you can ensure that the LLM only receives the data that the role is authorized to see.

The architecture works like this:

  1. One vector store holds all patient records with selective redaction at ingestion (contact info like emails and phone numbers permanently removed).
  2. One retrieval pipeline searches with the original question and pulls the most relevant records.
  3. Role-specific tokenization is applied to the retrieved context before sending it to the LLM. Each role has a different entity list that determines what gets tokenized (hidden) and what remains visible.
  4. The LLM only sees what the role is authorized to access. Everything else appears as opaque tokens.

Role-to-Policy Mapping

Here is how different healthcare roles map to Blindfold tokenization configurations:

RoleBlindfold PolicySeesRedacted
Doctorrole_doctorNames, conditions, medicationsContact info, SSN, financial data
Nurserole_nurseNames, conditionsContact info, SSN, financial data, DOB
Billingrole_billingNames, insurance, billing amountsContact info, SSN, clinical details
ResearcherBuilt-in strictNothing — fully de-identifiedEverything

The critical difference between roles is the entities list passed to blindfold.tokenize(). A doctor's entity list includes only contact information and financial data — everything else remains visible. A researcher uses the strict policy, which tokenizes every detectable entity. Same pipeline, same vector store, radically different access levels.

Implementation

Here is a complete, working implementation. It defines role-specific entity configurations, ingests patient records with selective contact-info redaction, and provides a query function that applies the appropriate tokenization based on the caller's role.

Role Definitions and Setup

python
from blindfold import Blindfold
from openai import OpenAI
import chromadb

blindfold = Blindfold(
    api_key="your-blindfold-key",
    region="us",  # HIPAA: keep data in the US
)
openai_client = OpenAI()
chroma = chromadb.PersistentClient(path="./vectorstore")
collection = chroma.get_or_create_collection("patient_records")

# Role -> entities to TOKENIZE (hide from the LLM)
# Entities NOT in the list remain visible to that role
ROLE_ENTITIES = {
    "doctor": [
        "email address",
        "phone number",
        "social security number",
    ],
    "nurse": [
        "email address",
        "phone number",
        "social security number",
        "date of birth",
    ],
    "billing": [
        "email address",
        "phone number",
        "social security number",
        "medical condition",
        "medication",
    ],
    "researcher": None,  # uses policy="strict" — tokenize everything
}

Notice the design: the ROLE_ENTITIES dictionary defines which entity types each role wants to hide. A doctor's list includes only contact info and SSN, which means names, medical conditions, and medications remain visible. A billing clerk's list adds medical conditions and medications, so those get tokenized and the clerk only sees names, insurance, and billing amounts. The researcher role uses None as a sentinel value to trigger the built-in strict policy, which tokenizes every entity type.

Ingestion: Selective Contact-Info Redaction

At ingestion time, permanently redact contact information from patient records before storing them in the vector database. This provides a baseline level of protection — emails, phone numbers, and SSNs are removed for all roles, and the role-specific tokenization at query time provides the additional filtering.

python
# Sample patient records
patient_records = [
    "Patient: Sarah Chen, DOB: 1985-03-14. SSN: 412-55-6789. "
    "Diagnosed with Type 2 Diabetes Mellitus on 2025-08-15. "
    "Prescribed Metformin 500mg twice daily. "
    "Insurance: BlueCross policy BC-2847193. Outstanding balance: $2,340. "
    "Contact: sarah.chen@email.com, (555) 012-3456.",

    "Patient: James Wilson, DOB: 1972-11-22. SSN: 523-66-8901. "
    "Diagnosed with Hypertension Stage 2 on 2025-06-10. "
    "Prescribed Lisinopril 20mg daily. Lab: BP 158/95. "
    "Insurance: Aetna policy AE-9182736. Outstanding balance: $890. "
    "Contact: j.wilson@provider.net, (555) 987-6543.",

    "Patient: Maria Rodriguez, DOB: 1990-07-08. SSN: 634-77-0123. "
    "Diagnosed with Major Depressive Disorder on 2025-09-20. "
    "Prescribed Sertraline 100mg daily. PHQ-9 score: 18. "
    "Insurance: UnitedHealth policy UH-4455667. Outstanding balance: $1,150. "
    "Contact: m.rodriguez@mail.com, (555) 234-5678.",
]

# Ingest with selective redaction: remove contact info permanently
for i, record in enumerate(patient_records):
    redacted = blindfold.redact(
        record,
        entities=[
            "email address",
            "phone number",
            "social security number",
        ],
    )
    # Store the redacted text — names, conditions, and insurance preserved
    # Contact info permanently removed
    collection.add(
        documents=[redacted.text],
        ids=[f"patient_{i}"],
    )

# After ingestion, vector store contains:
# "Patient: Sarah Chen, DOB: 1985-03-14. [SSN].
#  Diagnosed with Type 2 Diabetes Mellitus on 2025-08-15.
#  Prescribed Metformin 500mg twice daily.
#  Insurance: BlueCross policy BC-2847193. Outstanding balance: $2,340.
#  Contact: [EMAIL_ADDRESS], [PHONE_NUMBER]."

Query Function with Role-Based Tokenization

The query function is where the role-based access control happens. It retrieves context using the original question, then applies a different tokenization configuration based on the user's role before sending anything to the LLM.

python
def query_as_role(question: str, role: str, collection) -> str:
    # Step 1: Retrieve with the original question for best search results
    results = collection.query(
        query_texts=[question], n_results=3
    )
    context = "\n\n".join(results["documents"][0])

    # Step 2: Combine context + question into a single string
    prompt = f"Context:\n{context}\n\nQuestion: {question}"

    # Step 3: Apply role-specific tokenization
    if role == "researcher":
        # Researcher: tokenize EVERYTHING — full de-identification
        tokenized = blindfold.tokenize(prompt, policy="strict")
    else:
        # Other roles: tokenize only the entities in their deny list
        tokenized = blindfold.tokenize(
            prompt, entities=ROLE_ENTITIES[role]
        )

    # Step 4: Send tokenized prompt to the LLM
    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {
                "role": "system",
                "content": "You are a healthcare assistant. Answer using only the provided context.",
            },
            {"role": "user", "content": tokenized.text},
        ],
    )

    # Step 5: Detokenize the response to restore visible data
    ai_answer = response.choices[0].message.content
    return blindfold.detokenize(ai_answer, tokenized.mapping)


# Usage — same question, four different views
question = "What is Sarah Chen's current treatment plan?"

doctor_answer = query_as_role(question, "doctor", collection)
nurse_answer = query_as_role(question, "nurse", collection)
billing_answer = query_as_role(question, "billing", collection)
researcher_answer = query_as_role(question, "researcher", collection)

Single tokenize call: The context and question are combined into one string before tokenization. This ensures that if “Sarah Chen” appears in both the retrieved context and the question, she maps to the same token (e.g., <Person_1>) everywhere. Separate tokenize calls would produce independent numbering and break consistency.

What Each Role Sees

To make this concrete, here is what the same patient record looks like after role-specific tokenization. The original record in the vector store (after ingestion-time redaction of contact info) is:

python
# Original record (after ingestion-time redaction of contact info)
"Patient: Sarah Chen, DOB: 1985-03-14. [SSN]. "
"Diagnosed with Type 2 Diabetes Mellitus on 2025-08-15. "
"Prescribed Metformin 500mg twice daily. "
"Insurance: BlueCross policy BC-2847193. Outstanding balance: $2,340. "
"Contact: [EMAIL_ADDRESS], [PHONE_NUMBER]."

Doctor View

The doctor sees names, medical conditions, medications, and dates. Contact info and SSN were already removed at ingestion. The LLM receives and responds with full clinical detail:

Sarah Chen was diagnosed with Type 2 Diabetes Mellitus on 2025-08-15. She is currently prescribed Metformin 500mg twice daily. Her insurance is BlueCross policy BC-2847193 with an outstanding balance of $2,340.

Nurse View

The nurse sees names and conditions but not the date of birth (tokenized in addition to contact info). The response still contains clinical detail needed for care:

Sarah Chen was diagnosed with Type 2 Diabetes Mellitus. She is currently prescribed Metformin 500mg twice daily.

Billing View

The billing clerk sees names, insurance details, and billing amounts. Medical conditions and medications are tokenized — the clerk sees tokens like <Medical_Condition_1> in the prompt, and the LLM works around them:

Sarah Chen has insurance through BlueCross, policy number BC-2847193. The current outstanding balance is $2,340.

Researcher View

The researcher sees fully de-identified data. The strict policy tokenizes every entity — names, dates, conditions, medications, insurance, and financial amounts all become opaque tokens:

<Person_1> was treated for <Medical_Condition_1> and prescribed <Medication_1>. Insurance: <Organization_1> policy <ID_1>.

Four different views of the same data, all from one vector store and one retrieval pipeline. The only difference is the entities parameter passed to blindfold.tokenize() at query time.

Custom Policies via Dashboard

The code example above uses the entities parameter to define role permissions inline. This works well for prototyping, but in production you should centralize policy management using custom policies in the Blindfold dashboard at app.blindfold.dev.

With custom policies, you create named configurations like role_doctor, role_nurse, and role_billing in the dashboard. Each policy specifies which entity types to detect, which to tokenize, and which to leave visible. Then your code simplifies to:

python
# Production code — policies managed in the dashboard
def query_as_role(question: str, role: str, collection) -> str:
    results = collection.query(query_texts=[question], n_results=3)
    context = "\n\n".join(results["documents"][0])
    prompt = f"Context:\n{context}\n\nQuestion: {question}"

    # Policy name maps directly to the role
    policy_name = f"role_{role}"  # "role_doctor", "role_nurse", etc.
    tokenized = blindfold.tokenize(prompt, policy=policy_name)

    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are a healthcare assistant. Answer using only the provided context."},
            {"role": "user", "content": tokenized.text},
        ],
    )
    return blindfold.detokenize(response.choices[0].message.content, tokenized.mapping)

The advantage of dashboard-managed policies is operational: when a compliance officer decides that nurses should also see medication details, you update the role_nurse policy in the dashboard. No code changes, no redeployment, no pull request. The policy change takes effect immediately on the next API call.

HIPAA Compliance Considerations

Role-based tokenization directly addresses several HIPAA requirements. Here is how each principle maps to the architecture described above:

Minimum Necessary Rule

The minimum necessary standard requires that covered entities limit PHI disclosures to the minimum needed for the job function. Role-specific tokenization implements this directly — a billing clerk's tokenization configuration hides clinical data because it is not necessary for billing work. The LLM never receives clinical details for billing queries, which means there is no PHI over-disclosure even if the LLM provider logs the prompt.

Audit Trail

Every tokenize and detokenize call through Blindfold is logged with a timestamp, the policy or entity list used, the entity types detected, and a session identifier. This creates an automatic audit trail showing which role accessed what level of data at what time — exactly what HIPAA auditors look for when reviewing access controls.

PHI Never Reaches the LLM Provider

The LLM provider receives only tokenized text. Even for the doctor role (the least restrictive configuration), contact information and SSNs are tokenized before the prompt leaves your infrastructure. For the researcher role, the provider sees nothing but opaque tokens. This means the LLM provider is not a business associate handling PHI — it is processing de-identified data.

Data Residency

Configure Blindfold with region="us" to ensure that all PII processing (detection, tokenization, detokenization) happens on US-based infrastructure. This satisfies data residency requirements for organizations that cannot send PHI outside the United States, even temporarily.

Business Associate Agreement

Blindfold offers a BAA for healthcare customers. If your pipeline processes PHI (even briefly during tokenization), a BAA ensures that Blindfold is contractually bound to protect that data under HIPAA. Contact the Blindfold team to set this up before deploying to production.

python
# HIPAA-ready configuration
blindfold = Blindfold(
    api_key="your-blindfold-key",
    region="us",  # Data residency: all processing in the US
)

# Every call is automatically logged for audit purposes:
# - Timestamp
# - Policy or entity list used
# - Entity types detected
# - Session identifier
tokenized = blindfold.tokenize(
    prompt,
    policy="role_doctor",
)

Combining with Document-Level Access Control

Role-based tokenization controls what the LLM sees at the PII level — which entity types are visible vs. hidden. But healthcare organizations also need document-level access control: a cardiology nurse should only retrieve cardiology records, not psychiatry records. These are two separate layers that work together.

ChromaDB (and most vector databases) support metadata filtering. You can tag each document with a department, facility, or access level, then filter at query time based on the user's permissions. Combine this with role-based tokenization for defense in depth:

python
# Ingestion: tag documents with department metadata
collection.add(
    documents=[redacted_record],
    metadatas=[{
        "department": "cardiology",
        "facility": "main_campus",
        "access_level": "clinical",
    }],
    ids=[f"patient_{i}"],
)

# Query: apply BOTH document-level and PII-level filtering
def query_with_dual_access_control(question, user):
    # Layer 1: Document-level filter — only retrieve allowed documents
    results = collection.query(
        query_texts=[question],
        n_results=3,
        where={"department": user.department},  # document-level filter
    )
    context = "\n\n".join(results["documents"][0])
    prompt = f"Context:\n{context}\n\nQuestion: {question}"

    # Layer 2: PII-level filter — tokenize based on role
    if user.role == "researcher":
        tokenized = blindfold.tokenize(prompt, policy="strict")
    else:
        tokenized = blindfold.tokenize(
            prompt, entities=ROLE_ENTITIES[user.role]
        )

    # Send to LLM — both document-level and PII-level protections applied
    response = openai_client.chat.completions.create(
        model="gpt-4o-mini",
        messages=[
            {"role": "system", "content": "You are a healthcare assistant. Answer using only the provided context."},
            {"role": "user", "content": tokenized.text},
        ],
    )
    return blindfold.detokenize(
        response.choices[0].message.content, tokenized.mapping
    )

This dual-layer approach gives you two independent security controls:

  • Document-level filtering (ChromaDB metadata) controls which records are retrieved. A cardiology nurse only gets cardiology records.
  • PII-level tokenization (Blindfold entities/policy) controls what data within those records the LLM can see. The same cardiology nurse sees patient names and conditions but not SSNs or financial data.

Neither layer alone is sufficient. Document-level filtering does not prevent a nurse from seeing financial data in records they are allowed to access. PII-level tokenization does not prevent a cardiology nurse from retrieving psychiatry records. Together, they satisfy both HIPAA's minimum necessary rule and common organizational access policies.

Production Considerations

Moving from prototype to production requires attention to several operational details:

Role Verification

The role parameter in the query function must come from a trusted source — your identity provider, your hospital's Active Directory, or your application's session management. Never accept the role from client-side input. A common pattern is to extract the role from a JWT token issued by your IdP:

python
from your_auth_module import verify_token

def handle_query(request):
    # Extract role from verified JWT — never trust client input
    token = request.headers["Authorization"].split(" ")[1]
    claims = verify_token(token)
    role = claims["role"]  # "doctor", "nurse", "billing", "researcher"

    if role not in ROLE_ENTITIES:
        raise PermissionError(f"Unknown role: {role}")

    return query_as_role(request.body["question"], role, collection)

Error Handling

If the Blindfold API is unavailable, your system must fail closed — refuse to answer rather than sending unprotected PHI to the LLM. This is a critical safety requirement in healthcare:

python
try:
    tokenized = blindfold.tokenize(prompt, entities=ROLE_ENTITIES[role])
except Exception as e:
    # Fail closed — never send unprotected PHI to the LLM
    logger.error(f"Blindfold tokenization failed: {e}")
    return "Service temporarily unavailable. Please try again."

Performance

Tokenization adds approximately 100–200ms per query. For healthcare RAG systems where the LLM call itself takes 1–3 seconds, this is negligible. Detokenization is a local string replacement with no API call, so it adds effectively zero latency. For batch ingestion of large document sets, use blindfold.redact_batch() or AsyncBlindfold for concurrent processing.

Try It Yourself

Ready to implement role-based PII control in your healthcare RAG system? Here are the resources to get started:

The entire setup takes about twenty minutes. Define your role-to-entity mappings, create the corresponding policies in the dashboard, wire up the query function, and every query through your healthcare RAG pipeline automatically enforces the minimum necessary rule based on who is asking.

Start protecting sensitive data

Free plan includes 500K characters/month. No credit card required.