← Back to list

Keeping PII and PCI Out of Your LLM Pipeline: A Practical Guardrail Pattern (Part 1)

Large language models are remarkably good at doing exactly what you ask — including, unfortunately, processing sensitive data you never…

Krishnan Sriram · 2026-07-09 13:29 · 2 claps · 10.8 min read
#azure-foundry #azure-functions #apim #azure-ai-language-service #azure-content-safety
Open on Medium ↗
Wiki topics: LLM · Large Language Models SAF · Safety & Alignment ☁️ · DevOps & Cloud

Keeping PII and PCI Out of Your LLM Pipeline: A Practical Guardrail Pattern (Part 1)

Photo by Neum0* on Unsplash

Photo by Neum0 on Unsplash*

Large language models are remarkably good at doing exactly what you ask — including, unfortunately, processing sensitive data you never meant to send them. In financial services, that’s not a hypothetical risk: a stray Social Security number, credit card number, or bank routing number sitting in a prompt can turn a helpful chatbot into a compliance incident. Most teams throw cautionary notes “don’t type sensitive data” warning in the UI and call it a day. That’s not a control, it’s a suggestion. This series walks through building an actual guardrail — one that inspects every question before it reaches an LLM, classifies whether it’s even in-scope for the assistant to answer, and rejects anything carrying personally identifiable information (PII) or payment card data (PCI) before a single token gets generated. Part 1 covers the core detection and orchestration logic; Part 2 will include cloud native components like APIM, Content Safety, AI Language services in front of a model hosted in Azure AI Foundry. This is a follow up from our previous article, if you not had a chance to check it please do.

An SSN, a credit card number, and a home address walk into an LLM. The LLM says, “Sorry, we have a strict no-PII policy here.”

They say, “That’s fine, we’ll just wait in the logs instead.”

Here’s what we are trying to build in this section

We build a simple LangGraph app running locally, making two separate calls into the same Azure resource group — one to the Language service for PII/PCI detection, one to the GPT-4.1-mini deployment for scope classification and answering — both sitting inside a single AI Foundry AIServices resource.

Let’s get started

1. Create the resource group

RG=my-foundry-rg
LOCATION=eastus        
FOUNDRY_NAME=my-foundry-resource-01
FOUNDRY_PROJECT=my-foundry-project

az group create --name $RG --location $LOCATION

Every resource in the solution lives inside a single resource group, so it can be managed, billed, and torn down as one unit.

2. Create the AI Foundry resource

az cognitiveservices account create \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --kind AIServices \
  --sku S0 \
  --location $LOCATION \
  --custom-domain $FOUNDRY_NAME \
  --allow-project-management

This provisions the multi-service AIServices resource that hosts both the Language service and model deployments under one endpoint. --allow-project-management is set here because it can't be added later.

3. Create a Foundry project

az cognitiveservices account project create \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --project-name $FOUNDRY_PROJECT \
  --location $LOCATION

A project is the working container inside the resource where models get deployed and used — it’s the unit you’d open in the Foundry portal.

4. Verify provisioning succeeded

az cognitiveservices account project show \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --project-name $FOUNDRY_PROJECT \
  --query properties.provisioningState --output tsv
# Execution Results
Succeeded

A simple health check before moving forward, confirming the project reached Succeeded rather than silently failing.

5. Retrieve keys and endpoint

az cognitiveservices account keys list \
  --name $FOUNDRY_NAME \
  --resource-group $RG

FOUNDRY_ENDPOINT=$(az cognitiveservices account show \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --query properties.endpoint --output tsv)

FOUNDRY_KEY=$(az cognitiveservices account keys list \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --query key1 --output tsv)

These credentials are what the application code — and later, the Azure Function — uses to authenticate against the resource.

6. Deploy GPT-4.1-mini

az cognitiveservices account deployment create \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --deployment-name gpt-4.1-mini \
  --model-name gpt-4.1-mini \
  --model-version "2025-04-14" \
  --model-format OpenAI \
  --sku-name GlobalStandard \
  --sku-capacity 10

This is the model doing double duty in the graph — classifying whether a question is in scope, and generating the final answer once a request clears both gates.

7. Verify the deployment succeeded

az cognitiveservices account deployment show \
  --deployment-name gpt-4.1-mini \
  --name $FOUNDRY_NAME \
  --resource-group $RG \
  --query properties.provisioningState -o tsv

GPT deployments provision quickly with no external attestation step, unlike Anthropic models on Foundry, so this should return Succeeded almost immediately.

8. Capture the OpenAI-compatible endpoint and key

FOUNDRY_OPENAI_ENDPOINT="https://${FOUNDRY_NAME}.openai.azure.com"
FOUNDRY_OPENAI_KEY=$(az cognitiveservices account keys list \
  --name $FOUNDRY_NAME --resource-group $RG --query key1 -o tsv)

This is the specific endpoint format the LangGraph app’s AzureChatOpenAI client needs — distinct from the general resource endpoint used for the Language service calls.

Here’s what our LangGraph code does

Let’s get started with the code

1. Initialize the project

bash

mkdir pii_pci_check && cd pii_pci_check
uv init --name pii-pci-check --python 3.12

This creates pyproject.toml, .python-version, and a starter main.py — you'll drop your actual script in as lgraph_text_check.py instead.

2. Add dependencies

uv add langgraph langchain-openai azure-ai-textanalytics azure-core pydantic

This resolves and pins everything into pyproject.toml and uv.lock, and creates the .venv automatically — no separate venv/pip install step needed.

3. Add the script

Save your code as lgraph_text_check.py in the project root (same level as pyproject.toml).

import os
from typing import TypedDict
from pydantic import BaseModel
from langgraph.graph import StateGraph, END
from langchain_openai import AzureChatOpenAI
from azure.ai.textanalytics import TextAnalyticsClient
from azure.core.credentials import AzureKeyCredential
from azure.core.exceptions import HttpResponseError

PCI_CATEGORIES = {
    "CreditCardNumber",
    "USBankAccountNumber",
    "InternationalBankingAccountNumber",
    "SWIFTCode",
    "ABARoutingNumber",
    "SortCode",
}

llm = AzureChatOpenAI(
    azure_endpoint=os.environ["FOUNDRY_OPENAI_ENDPOINT"],
    api_key=os.environ["FOUNDRY_OPENAI_KEY"],
    azure_deployment="gpt-4.1-mini",
    api_version="2024-10-21",
)

lang_client = TextAnalyticsClient(
    endpoint=os.environ["FOUNDRY_ENDPOINT"],
    credential=AzureKeyCredential(os.environ["FOUNDRY_KEY"]),
)

class FinancialClassification(BaseModel):
    is_financial: bool

classifier_llm = llm.with_structured_output(FinancialClassification)

class GraphState(TypedDict):
    question: str
    is_financial: bool
    has_pii: bool
    has_pci: bool
    status: str
    answer: str

def classify_financial(state: GraphState) -> GraphState:
    prompt = (
        "Is this question about finance, banking, investing, payments, "
        "loans, mortgages, credit, or financial institutions?\n\n"
        f"Question: {state['question']}"
    )
    result = classifier_llm.invoke(prompt)
    state["is_financial"] = result.is_financial
    return state

def route_financial(state: GraphState) -> str:
    return "pii_pci_check" if state["is_financial"] else "reject_non_financial"

def reject_non_financial(state: GraphState) -> GraphState:
    state["status"] = "rejected_non_financial"
    return state

def pii_pci_check(state: GraphState) -> GraphState:
    try:
        results = lang_client.recognize_pii_entities([state["question"]], language="en")
    except HttpResponseError:
        state["status"] = "error_language_service"
        state["has_pii"] = True
        state["has_pci"] = True
        return state

    doc_result = results[0]
    if doc_result.is_error:
        state["status"] = "error_language_service"
        state["has_pii"] = True
        state["has_pci"] = True
        return state

    has_pii = False
    has_pci = False
    for entity in doc_result.entities:
        if entity.category in PCI_CATEGORIES:
            has_pci = True
        else:
            has_pii = True

    state["has_pii"] = has_pii
    state["has_pci"] = has_pci
    return state

def route_pii_pci(state: GraphState) -> str:
    if state["has_pii"] or state["has_pci"]:
        return "reject_pii_pci"
    return "answer_question"

def reject_pii_pci(state: GraphState) -> GraphState:
    if state.get("status") != "error_language_service":
        if state["has_pci"]:
            state["status"] = "rejected_pci"
        else:
            state["status"] = "rejected_pii"
    return state

def answer_question(state: GraphState) -> GraphState:
    response = llm.invoke(state["question"])
    state["answer"] = response.content
    state["status"] = "answered"
    return state

graph = StateGraph(GraphState)
graph.add_node("classify_financial", classify_financial)
graph.add_node("reject_non_financial", reject_non_financial)
graph.add_node("pii_pci_check", pii_pci_check)
graph.add_node("reject_pii_pci", reject_pii_pci)
graph.add_node("answer_question", answer_question)

graph.set_entry_point("classify_financial")
graph.add_conditional_edges("classify_financial", route_financial)
graph.add_conditional_edges("pii_pci_check", route_pii_pci)
graph.add_edge("reject_non_financial", END)
graph.add_edge("reject_pii_pci", END)
graph.add_edge("answer_question", END)

app = graph.compile()

def run(question: str) -> GraphState:
    return app.invoke({"question": question})

if __name__ == "__main__":
    tests = [
        "What is the current interest rate on a 30-year mortgage?",
        "What's the weather like in Paris today?",
        "My card number is 4532 0151 1283 0366, can you check my account balance?",
    ]
    for q in tests:
        result = run(q)
        print(q, "->", result["status"])

5. Run it

uv run lgraph_text_check.py

# Execution result

/Users/krishnansriram/Projects/Azure/FoundryLanguage/pii_pci_check/.venv/lib/python3.13/site-packages/pydantic/main.py:475: UserWarning: Pydantic serializer warnings:
  PydanticSerializationUnexpectedValue(Expected `none` - serialized value may not be as expected [field_name='parsed', input_value=FinancialClassification(is_financial=True), input_type=FinancialClassification])
  return self.__pydantic_serializer__.to_python(
What is the current interest rate on a 30-year mortgage? -> answered
/Users/krishnansriram/Projects/Azure/FoundryLanguage/pii_pci_check/.venv/lib/python3.13/site-packages/pydantic/main.py:475: UserWarning: Pydantic serializer warnings:
  PydanticSerializationUnexpectedValue(Expected `none` - serialized value may not be as expected [field_name='parsed', input_value=FinancialClassification(is_financial=False), input_type=FinancialClassification])
  return self.__pydantic_serializer__.to_python(
What's the weather like in Paris today? -> rejected_non_financial
/Users/krishnansriram/Projects/Azure/FoundryLanguage/pii_pci_check/.venv/lib/python3.13/site-packages/pydantic/main.py:475: UserWarning: Pydantic serializer warnings:
  PydanticSerializationUnexpectedValue(Expected `none` - serialized value may not be as expected [field_name='parsed', input_value=FinancialClassification(is_financial=True), input_type=FinancialClassification])
  return self.__pydantic_serializer__.to_python(
My card number is 4532 0151 1283 0366, can you check my account balance? -> rejected_pci

uv run automatically syncs the environment against the lockfile before executing, so if you or a teammate pulls the repo fresh, the first uv run will provision the exact same dependency versions without any manual setup step.

We now have a good start

Photo by Irham Setyaki on Unsplash

Photo by Irham Setyaki on Unsplash

Local to Azure function

Let’s take the next step of moving our LangGraph implementation over to an Azure function. This way we have all required pieces operating in cloud.

Here’s the design we are going for

Create the storage account

STORAGE_NAME=piipcifuncstorage01
FUNCTION_APP_NAME=pii-pci-check-func
UMI_NAME=pii-pci-func-umi

az storage account create \
  --name $STORAGE_NAME \
  --resource-group $RG \
  --location $LOCATION \
  --sku Standard_LRS

Azure Functions requires a backing storage account for its own runtime state and triggers — this is infrastructure the platform needs, not something the application logic touches directly.

Create the Function App

az functionapp create \
  --resource-group $RG \
  --consumption-plan-location $LOCATION \
  --runtime python \
  --runtime-version 3.12 \
  --functions-version 4 \
  --name $FUNCTION_APP_NAME \
  --storage-account $STORAGE_NAME \
  --os-type Linux

This provisions the actual compute host that runs function_app.py, configured for the Python 3.12 runtime on a Linux Consumption plan.

Set app settings with keys (initial version)

az functionapp config appsettings set \
  --name $FUNCTION_APP_NAME \
  --resource-group $RG \
  --settings \
    FOUNDRY_ENDPOINT=$FOUNDRY_ENDPOINT \
    FOUNDRY_KEY=$FOUNDRY_KEY \
    FOUNDRY_OPENAI_ENDPOINT=$FOUNDRY_OPENAI_ENDPOINT \
    FOUNDRY_OPENAI_KEY=$FOUNDRY_OPENAI_KEY

The first working deployment used static API keys, stored as app settings — functional, but exactly the credential-sprawl risk this whole exercise sets out to remove.

Create the user-assigned managed identity

az identity create \
  --name $UMI_NAME \
  --resource-group $RG \
  --location $LOCATION

This provisions a standalone Entra ID identity, independent of any single resource’s lifecycle, that can be attached to the Function App and granted access elsewhere.

Attach the UMI to the Function App

UMI_CLIENT_ID=$(az identity show --name $UMI_NAME --resource-group $RG --query clientId -o tsv)
UMI_PRINCIPAL_ID=$(az identity show --name $UMI_NAME --resource-group $RG --query principalId -o tsv)
UMI_RESOURCE_ID=$(az identity show --name $UMI_NAME --resource-group $RG --query id -o tsv)

az functionapp identity assign \
  --name $FUNCTION_APP_NAME \
  --resource-group $RG \
  --identities $UMI_RESOURCE_ID

This is what lets the running Function actually request tokens as that identity — without this, the identity exists but the app has no way to use it.

Grant RBAC roles on the Foundry resource

FOUNDRY_RESOURCE_ID=$(az cognitiveservices account show \
  --name $FOUNDRY_NAME --resource-group $RG --query id -o tsv)

az role assignment create \
  --assignee $UMI_PRINCIPAL_ID \
  --role "Cognitive Services User" \
  --scope $FOUNDRY_RESOURCE_ID

az role assignment create \
  --assignee $UMI_PRINCIPAL_ID \
  --role "Cognitive Services OpenAI User" \
  --scope $FOUNDRY_RESOURCE_ID

Two roles are needed because the app calls two different data planes on the same resource — Language service detection and OpenAI chat completions each have their own permission scope.

Swap app settings from keys to the UMI client ID

az functionapp config appsettings delete \
  --name $FUNCTION_APP_NAME \
  --resource-group $RG \
  --setting-names FOUNDRY_KEY FOUNDRY_OPENAI_KEY

az functionapp config appsettings set \
  --name $FUNCTION_APP_NAME \
  --resource-group $RG \
  --settings UMI_CLIENT_ID=$UMI_CLIENT_ID

Disable local authentication on the Foundry resource

SUBSCRIPTION_ID=$(az account show --query id -o tsv)

az rest --method patch \
  --url "https://management.azure.com/subscriptions/${SUBSCRIPTION_ID}/resourceGroups/${RG}/providers/Microsoft.CognitiveServices/accounts/${FOUNDRY_NAME}?api-version=2023-05-01" \
  --body '{"properties": {"disableLocalAuth": true}}'

This is the actual enforcement step — it shuts off key-based access entirely at the resource level, regardless of whether any key still exists somewhere.

Build function

We start with function setup

mkdir pii_pci_check_fn
cd pii_pci_check_fn && func init . --worker-runtime python --model V2

Let’s get all the libraries needed in requirements.txt

# Uncomment to enable Azure Monitor OpenTelemetry
# Ref: aka.ms/functions-azure-monitor-python 
# azure-monitor-opentelemetry 

azure-functions 
langgraph 
langchain-openai 
azure-ai-textanalytics 
azure-core 
pydantic
azure-identity

Let’s now get the function code in — function_app.py

import os
import json
import logging
import azure.functions as func
from typing import TypedDict
from pydantic import BaseModel
from langgraph.graph import StateGraph, END
from langchain_openai import AzureChatOpenAI
from azure.ai.textanalytics import TextAnalyticsClient
from azure.identity import ManagedIdentityCredential, get_bearer_token_provider
from azure.core.exceptions import HttpResponseError

app = func.FunctionApp()

PCI_CATEGORIES = {
    "CreditCardNumber",
    "USBankAccountNumber",
    "InternationalBankingAccountNumber",
    "SWIFTCode",
    "ABARoutingNumber",
    "SortCode",
}

credential = ManagedIdentityCredential(client_id=os.environ["UMI_CLIENT_ID"])
token_provider = get_bearer_token_provider(credential, "https://cognitiveservices.azure.com/.default")

llm = AzureChatOpenAI(
    azure_endpoint=os.environ["FOUNDRY_OPENAI_ENDPOINT"],
    azure_ad_token_provider=token_provider,
    azure_deployment="gpt-4.1-mini",
    api_version="2024-10-21",
)

lang_client = TextAnalyticsClient(
    endpoint=os.environ["FOUNDRY_ENDPOINT"],
    credential=credential,
)

classifier_llm = llm.with_structured_output(
    type("FinancialClassification", (BaseModel,), {"__annotations__": {"is_financial": bool}})
)

class GraphState(TypedDict):
    question: str
    is_financial: bool
    has_pii: bool
    has_pci: bool
    status: str
    answer: str

def classify_financial(state: GraphState) -> GraphState:
    prompt = (
        "Is this question about finance, banking, investing, payments, "
        "loans, mortgages, credit, or financial institutions?\n\n"
        f"Question: {state['question']}"
    )
    result = classifier_llm.invoke(prompt)
    state["is_financial"] = result.is_financial
    return state

def route_financial(state: GraphState) -> str:
    return "pii_pci_check" if state["is_financial"] else "reject_non_financial"

def reject_non_financial(state: GraphState) -> GraphState:
    state["status"] = "rejected_non_financial"
    return state

def pii_pci_check(state: GraphState) -> GraphState:
    try:
        results = lang_client.recognize_pii_entities([state["question"]], language="en")
    except HttpResponseError:
        state["status"] = "error_language_service"
        state["has_pii"] = True
        state["has_pci"] = True
        return state

    doc_result = results[0]
    if doc_result.is_error:
        state["status"] = "error_language_service"
        state["has_pii"] = True
        state["has_pci"] = True
        return state

    has_pii = False
    has_pci = False
    for entity in doc_result.entities:
        if entity.category in PCI_CATEGORIES:
            has_pci = True
        else:
            has_pii = True

    state["has_pii"] = has_pii
    state["has_pci"] = has_pci
    return state

def route_pii_pci(state: GraphState) -> str:
    if state["has_pii"] or state["has_pci"]:
        return "reject_pii_pci"
    return "answer_question"

def reject_pii_pci(state: GraphState) -> GraphState:
    if state.get("status") != "error_language_service":
        state["status"] = "rejected_pci" if state["has_pci"] else "rejected_pii"
    return state

def answer_question(state: GraphState) -> GraphState:
    response = llm.invoke(state["question"])
    state["answer"] = response.content
    state["status"] = "answered"
    return state

graph = StateGraph(GraphState)
graph.add_node("classify_financial", classify_financial)
graph.add_node("reject_non_financial", reject_non_financial)
graph.add_node("pii_pci_check", pii_pci_check)
graph.add_node("reject_pii_pci", reject_pii_pci)
graph.add_node("answer_question", answer_question)

graph.set_entry_point("classify_financial")
graph.add_conditional_edges("classify_financial", route_financial)
graph.add_conditional_edges("pii_pci_check", route_pii_pci)
graph.add_edge("reject_non_financial", END)
graph.add_edge("reject_pii_pci", END)
graph.add_edge("answer_question", END)

compiled_graph = graph.compile()

@app.route(route="check", methods=["POST"], auth_level=func.AuthLevel.FUNCTION)
def check(req: func.HttpRequest) -> func.HttpResponse:
    try:
        body = req.get_json()
        question = body.get("question", "").strip()
    except ValueError:
        return func.HttpResponse(
            json.dumps({"error": "Request body must be valid JSON with a 'question' field"}),
            status_code=400,
            mimetype="application/json",
        )

    if not question:
        return func.HttpResponse(
            json.dumps({"error": "Missing 'question' field"}),
            status_code=400,
            mimetype="application/json",
        )

    logging.info(f"Processing question of length {len(question)}")
    result = compiled_graph.invoke({"question": question})

    return func.HttpResponse(
        json.dumps({
            "status": result["status"],
            "answer": result.get("answer"),
        }),
        status_code=200,
        mimetype="application/json",
    )

Deploy & Test

This is the local structure I have.

Azure function setup

Azure function setup

First publish the function and make sure your deployment is success before you start testing.

func azure functionapp publish $FUNCTION_APP_NAME
# Execution results
.................
.................
Deployment successful. deployer = Push-Deployer deploymentPath = Functions App ZipDeploy. Extract zip. Remote build.
Remote build succeeded!
[2026-07-08T12:09:23.836Z] Syncing triggers...
Functions in pii-pci-check-func:
    check - [httpTrigger]
        Invoke url: https://pii-pci-check-func.azurewebsites.net/api/check

# Time to test

curl -i "https://${FUNCTION_APP_NAME}.azurewebsites.net/api/check?code=${FUNCTION_KEY}" \
  -H "Content-Type: application/json" \
  -d '{"question": "What is the current interest rate on a 30-year mortgage?"}'

# Execution results
HTTP/1.1 200 OK
Content-Type: application/json
Date: Wed, 08 Jul 2026 12:00:08 GMT
Server: Kestrel
Transfer-Encoding: chunked

{"status": "answered", "answer": "I don't have real-time access to current financial data. For the most up-to-date 30-year mortgage interest rates, I recommend checking reliable sources such as:\n\n- Bank websites (e.g., Wells Fargo, Bank of America)\n- Financial news sites (e.g., CNBC, Bloomberg)\n- Mortgage lenders or brokers\n- Government websites like Freddie Mac\u2019s Primary Mortgage Market Survey\n\nRates can vary based on your credit score, loan amount, location, and other factors, so it\u2019s a good idea to get personalized quotes."}%

Confirms the end-to-end pipeline works in Azure before touching authentication — a deliberate checkpoint, so any later failure is known to be about identity, not the app itself.

What’s Next

This works as a script on a laptop than turned this LangGraph into an **Azure Function. Next is an important transtion to use `Azure API Management** in front of the Azure AI Foundry deployment itself, so the guardrail isn’t just application-layer logic that a future engineer could accidentally bypass, but an enforced network and policy boundary between callers and the model. That’s the difference between “we built a filter” and “we built a control.” We then add actual PII and PCI check withAI Languagecapability in AI Foundry as well as enablecontent security` capability.

Until then keep reading and stay safe!!


메타데이터
post_id
36284e7fbd6b
slug
keeping-pii-and-pci-out-of-your-llm-pipeline-a-practical-guardrail-pattern-part-1-36284e7fbd6b
url
https://medium.com/@krishnan.srm/keeping-pii-and-pci-out-of-your-llm-pipeline-a-practical-guardrail-pattern-part-1-36284e7fbd6b
canonical_url
https://medium.com/@krishnan.srm/keeping-pii-and-pci-out-of-your-llm-pipeline-a-practical-guardrail-pattern-part-1-36284e7fbd6b
author_url
https://medium.com/@krishnan.srm
status
ok
fetched_at
2026-07-18 12:10:31