Free lesson · GenAI Security Engineering
Integrate PII defense with LiteLLM gateway
Build LiteLLM request/response hooks for PII scanning. Implement per-tenant PII policies and scan result caching.
Course: AI Security Engineering · Chapter 8 · PII Leakage Engineering
Free to read — no subscription required.
Introduction
Engineers often build prompt pipelines that inadvertently expose customer names, Social Security numbers, or credit card numbers to third-party LLM providers — each unscanned request is a potential compliance incident. Adding a scanning layer at the gateway level stops PII before it leaves your network, without requiring every application team to implement their own detection logic. By the end of this lesson, you will be able to implement a LiteLLM CustomLogger hook that integrates Presidio's analyzer and anonymizer engines to enforce per-tenant PII policies on both inbound prompts and outbound model responses.
Key Terminology
CustomLogger— LiteLLM's baseclassthatPIIGatewayHooksubclasses to inject logic into the proxy's request lifecycle, providing theasync_pre_call_hookandasync_log_success_eventextension points used throughout this lesson.async_pre_call_hook— ACustomLoggermethod that fires synchronously before a request is forwarded to the LLM provider; used here to extract the calling tenant's policy, run Presidio analysis ondata["messages"], and either raise anHTTPException(block mode) or anonymize detected spans in-place (redact mode).async_log_success_event— ACustomLoggermethod that fires after the LLM provider returns a completion; used here to scanresponse_obj.choices[].message.contentagainst the tenant'sresponse_entities(falling back toblocked_entities) and redact any PII before the response reaches the caller.- Per-tenant policy registry — A dictionary (
TENANT_POLICIES) mapping tenant IDs to enforcement configuration objects containingblocked_entities,threshold,mode,reversible, and optionallyresponse_entities; the hook resolves the active policy by readingtenant_idfromdata["metadata"]on each request. - Asymmetric PII policy — A configuration pattern in which
response_entitiesspecifies a distinct set of entity types for response-side scanning, separate from theblocked_entitiesused on inbound prompts, enabling stricter or entity-different enforcement on model completions than on the prompts that produced them. - Enforcement mode — The
modefield in a tenant policy:"block"causes the hook to raise HTTP 400 and abort the request the moment a detection clears the threshold, while"redact"replaces detected spans with entity-type placeholder tokens (e.g.,<US_SSN>) and forwards the sanitized text to the provider.
Concepts
Why Enforce at the Gateway Rather Than in Each Application
When individual application teams each implement their own PII scanning, policies fragment silently: one team sets a loose threshold, another forgets to include SSNs, a third scans prompts but never touches responses. A single missed scan is a compliance incident. Placing the enforcement hook in the LiteLLM gateway centralizes the invariant — every request and every response flows through PIIGatewayHook regardless of which upstream service originated the call. Compliance teams then audit and update one policy registry rather than coordinating across every application. This is the architectural rationale behind subclassing CustomLogger instead of embedding Presidio calls in each service's prompt-building logic.
The LiteLLM Hook Lifecycle: Two Interception Points
LiteLLM's CustomLogger exposes two distinct interception points that together create a bidirectional filter. async_pre_call_hook fires synchronously in the outbound path: the hook can inspect and mutate the messages list before any bytes leave the network, or raise an HTTPException to abort the request entirely. async_log_success_event fires after the provider returns: it can inspect and mutate response_obj.choices[].message.content before the completion reaches the caller. Neither hook can substitute for the other — a pre-call block prevents PII from reaching the provider, but only the success event hook can catch PII the model itself generates in its reply.
Per-Tenant Policies and the Block/Redact Decision
Different tenants carry different risk profiles, which is why the policy registry maps each tenant_id to its own enforcement contract rather than applying a single global rule. The blocked_entities list scopes which Presidio entity types are even checked — types absent from the list are silently ignored even if recognizers would otherwise flag them. The threshold field then filters by confidence, letting high-sensitivity tenants reject borderline detections and low-sensitivity tenants accept them. The mode field determines what happens when a detection clears both gates: "block" aborts the request with HTTP 400, appropriate when no PII should reach the provider at all; "redact" replaces the detected span with an entity-type placeholder and forwards the sanitized text (see Code Walkthrough).
Asymmetric Request and Response Enforcement
A subtlety the lesson surfaces explicitly: the entity types you want to block in a prompt are not necessarily the same ones you need to scrub from a response. A tenant might intentionally include a customer's first name in a prompt for personalization, yet require that any name the model echoes back be removed before the completion reaches the client. The optional response_entities field captures this asymmetry — when present, the response hook (async_log_success_event) uses it instead of blocked_entities. When absent, the response hook falls back to blocked_entities, applying the same entity list in both directions. This fallback is the default behavior for tenants that do not need direction-specific enforcement (see Code Walkthrough).
Code Walkthrough
Now that you understand per-tenant policy configuration — the blocked_entities list, threshold, mode, and reversible flags that define each tenant's enforcement contract — we can translate that structure into a LiteLLM hook that enforces those policies on every request and response.
The PIIGatewayHook class subclasses LiteLLM's CustomLogger and holds shared references to a Presidio AnalyzerEngine and AnonymizerEngine, along with a policy registry keyed by tenant ID. The async_pre_call_hook method extracts the calling tenant's policy, scans the outbound prompt text, and either raises a 400 error (block mode) or anonymizes the detected spans (redact mode) before the request reaches the LLM provider:
Code snippetpython
1from presidio_analyzer import AnalyzerEngine 2from presidio_anonymizer import AnonymizerEngine 3from litellm.integrations.custom_logger import CustomLogger 4from fastapi import HTTPException 5 6TENANT_POLICIES = { 7 "tenant_a": { 8 "blocked_entities": ["PERSON", "CREDIT_CARD", "US_SSN"], 9 "threshold": 0.85, 10 "mode": "redact", 11 "reversible": False, 12 }, 13 "tenant_b": { 14 "blocked_entities": ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER"], 15 "response_entities": ["PERSON", "EMAIL_ADDRESS"], 16 "threshold": 0.75, 17 "mode": "block", 18 "reversible": False, 19 }, 20} 21 22class PIIGatewayHook(CustomLogger): 23 def __init__(self): 24 self.analyzer = AnalyzerEngine() 25 self.anonymizer = AnonymizerEngine() 26 self.policies = TENANT_POLICIES 27 28 async def async_pre_call_hook(self, user_api_key_dict, cache, data, call_type): 29 tenant_id = (data.get("metadata") or {}).get("tenant_id", "default") 30 policy = self.policies.get(tenant_id) 31 if not policy: 32 return data 33 34 text = " ".join( 35 m.get("content", "") 36 for m in data.get("messages", []) 37 if isinstance(m.get("content"), str) 38 ) 39 entities = policy["blocked_entities"] 40 threshold = policy.get("threshold", 0.8) 41 results = self.analyzer.analyze(text=text, entities=entities, language="en") 42 hits = [r for r in results if r.score >= threshold] 43 44 if not hits: 45 return data 46 47 if policy.get("mode") == "block": 48 raise HTTPException( 49 status_code=400, detail="PII Policy Violation: request blocked" 50 ) 51 52 for msg in data.get("messages", []): 53 if isinstance(msg.get("content"), str): 54 anonymized = self.anonymizer.anonymize( 55 text=msg["content"], analyzer_results=hits 56 ) 57 msg["content"] = anonymized.text 58 return data
The response-side hook mirrors this pattern. It reads response_entities from the policy — falling back to blocked_entities when the field is absent, which is the asymmetric-policy behavior described in the Concepts section — then scans the model's completion and anonymizes any hits before they reach the client:
Code snippetpython
1 async def async_log_success_event(self, kwargs, response_obj, start_time, end_time): 2 tenant_id = (kwargs.get("metadata") or {}).get("tenant_id", "default") 3 policy = self.policies.get(tenant_id) 4 if not policy: 5 return 6 7 entities = policy.get("response_entities", policy["blocked_entities"]) 8 threshold = policy.get("threshold", 0.8) 9 for choice in getattr(response_obj, "choices", []): 10 content = getattr(getattr(choice, "message", None), "content", None) 11 if isinstance(content, str): 12 results = self.analyzer.analyze( 13 text=content, entities=entities, language="en" 14 ) 15 hits = [r for r in results if r.score >= threshold] 16 if hits: 17 anonymized = self.anonymizer.anonymize( 18 text=content, analyzer_results=hits 19 ) 20 choice.message.content = anonymized.text
You'll know it works when a prompt containing a synthetic SSN is either rejected with HTTP 400 (block mode) or returned with the SSN replaced by a <US_SSN> token (redact mode), and when the same tenant's response text has any model-generated PII scrubbed before the completion reaches the client.
Do's and Don'ts
Now that you have worked through the implementation, the practices below separate a durable approach from a fragile one.
Do's
- ✓Do instantiate
AnalyzerEngineandAnonymizerEngineonce inPIIGatewayHook.__init__— both engines load spaCy NLP models on construction; sharing them asself.analyzerandself.anonymizeracross all hook invocations avoids reloading those models on every gateway request, which would add hundreds of milliseconds of per-call latency at scale. - ✓Do filter
analyzer.analyze()results tohits = [r for r in results if r.score >= threshold]before passing them toanonymizer.anonymize(analyzer_results=hits)— the per-tenantthresholdfield is each tenant's confidence cutoff; passing the rawresultslist instead anonymizes low-confidence detections that are likely false positives, over-redacting benign content against the tenant's stated policy. - ✓Do implement both
async_pre_call_hookandasync_log_success_eventin the samePIIGatewayHookclass— the pre-call hook blocks or redacts PII in outbound prompts before they reach the LLM provider, but withoutasync_log_success_event, model-generated PII in completions flows back to the client entirely unscanned.
Don'ts
- ✗Don't pass raw
analyzer.analyze()results directly toanonymizer.anonymize()without threshold filtering — the unfiltered list includes every detection regardless of confidence score, causingtenant_a's 0.85 threshold to be silently ignored and redacting false-positive spans that should have passed through. - ✗Don't hardcode the response-scan entity list as identical to
blocked_entities— the asymmetric-policy design usespolicy.get("response_entities", policy["blocked_entities"])so tenants liketenant_bcan enforce a different entity set on model completions than on inbound prompts; usingblocked_entitiesunconditionally inasync_log_success_eventcollapses both sides of the policy into one and breaks any tenant that definesresponse_entities. - ✗Don't extract
tenant_idfrom anywhere other than(data.get("metadata") or {}).get("tenant_id")— LiteLLM forwards caller-supplied metadata through thedata["metadata"]dict; reading it from headers, query params, or a directdata["tenant_id"]key misses the actual routing path and falls through to the"default"policy, silently applying no enforcement for tenants whose IDs never match.
This lesson is free to read. Its 3 hands-on labs — real code, in a cloud IDE — are part of the GenAI Security Engineering subscription.
From · cancel anytime
More free lessons in AI Security Engineering
- Ch 4Deploy multi-stage safety pipeline on GKE
- Ch 6Deploy RAG defense system on GKE with pgvector
- Ch 8Integrate PII defense with LiteLLM gatewayYou are here
- Ch 8Deploy PII defense pipeline on GKE
- Ch 11Detect tool poisoning in MCP tool descriptions
- Ch 11Deploy secure MCP infrastructure on GKE
- Ch 12Monitor GKE security posture continuously