Skip to main content
Version: 1.2.0

PII Detection and Redaction

DVARA detects and enforces policies on Personally Identifiable Information (PII) and Protected Health Information (PHI) in prompts and LLM responses. This prevents sensitive data from being forwarded to external LLM providers without explicit authorization.

How It Works

PII enforcement runs at two points in the LLM request lifecycle:

  1. Request scanning — After policy evaluation, before dispatching to the LLM provider. Detects PII in user messages, tool result blocks, and — on function-calling requests — assistant tool-call arguments (tool_calls[].arguments).
  2. Response scanning — After receiving the LLM response, before returning to the client. Detects PII in assistant messages (output leak detection).

Additionally, PII is always stripped from requests before they are written to the response cache.

For MCP (Model Context Protocol) traffic, the DVARA MCP Proxy runs the same PII enforcement against tool calls:

  1. Argument scanning — For tools/call operations, the proxy recursively scans every string value in the tool-call arguments before forwarding to the upstream MCP server.
  2. Response scanning — After the MCP server responds (2xx only), the proxy recursively scans every string value in the response body for PII output leaks before returning to the agent.

MCP PII scanning uses the same configuration (global dvara.llm-gateway.pii.* and per-tenant pii.* metadata) and the same detection engine as LLM PII scanning.

Supported PII Types

TypeLabelDetection Method
Email addressesemailRegex pattern
US phone numbersphone_usRegex (with optional +1)
International phone numbersphone_intlRegex (E.164 format)
Social Security NumbersssnRegex (rejects 000/666/9xx prefixes)
Credit card numberscredit_cardRegex + Luhn checksum validation
Dates of birthdobRegex (MM/DD/YYYY or MM-DD-YYYY)
IPv4 addressesipv4Regex
US passport numberspassport_usRegex
Driver's license numbersdrivers_licenseKeyword (driver's license) + alphanumeric ID
IBAN numbersibanRegex (international bank account)
Medical Record NumbersmrnKeyword (MRN/Medical Record) + digits
DEA numbersdeaFormat regex + DEA checksum validation
NPI numbersnpiContext keyword "NPI" required + Luhn checksum
Person namesperson_nameSalutation heuristic (Mr/Mrs/Dr/Prof), confidence 0.7
India AadhaaraadhaarRegex + Verhoeff check-digit validation
India PANpanRegex + format/structural validation (holder-type char; not a numeric checksum)

Aadhaar and PAN are validated so an arbitrary 12-digit number or AAAAA9999A-shaped string isn't flagged: Aadhaar's trailing digit is a Verhoeff check digit (a wrong or transposed digit is rejected), and PAN is validated structurally — 5 letters + 4 digits + 1 letter with a recognised holder-type 4th character (PAN carries no numeric checksum). A Verhoeff-validated Aadhaar outranks a looser phone-number match on the same digits.

Actions

Each PII detection can trigger one of three actions:

ActionBehaviorAudit Event
LOGLog the detection, forward request unchangedPII_DETECTED
BLOCKReject the request with HTTP 400, type: pii_violation, code: pii_detectedPII_DETECTED
REDACTReplace PII with reversible tokens, forward modified requestPII_REDACTED

When REDACT is active, PII values are replaced with tokens like {{PII_EMAIL_a1b2c3d4}}. The original values are encrypted (AES-256-GCM) and held in a durable, tenant-scoped token store (survives restarts and is shared across replicas). Authorized admins can restore the original text on demand via POST /v1/admin/pii/detokenize.

Transparent round-trip (opt-in). When pii.auto-detokenize-response=true for a tenant (default off), tokens that were minted from that request's PII and echoed back by the model are automatically restored in the response the client receives — a PII_RESPONSE_DETOKENIZED audit event records it (lengths/counts only, never the revealed text). Restoration is request-scoped (only tokens the request carried, so any PII the model newly leaks stays masked) and runs after the response is written to the semantic cache, so the cache only ever holds the tokenized form. Non-streaming /v1/chat/completions only.

Response scanning emits a PII_OUTPUT_LEAK audit event on every detection. The response body is rewritten with tokenized placeholders only when the tenant action is REDACT; on LOG the response is returned unchanged, and BLOCK is not applied to response scanning (the audit event is your signal).

Configuration

Global Configuration

Add to application.yml:

dvara:
llm-gateway:
pii:
enabled: true # enable PII scanning
provider: regex # regex (default); a Presidio endpoint alone enables layered NER (see below)
default-action: LOG # LOG, BLOCK, or REDACT
scan-responses: true # scan LLM responses for output leaks
auto-detokenize-response: false # opt-in transparent round-trip (default off)
strip-before-cache: true # always redact before caching
token-encryption-password: ${DVARA_LLM_GATEWAY_PII_TOKEN_ENCRYPTION_PASSWORD:}
max-tokens-per-tenant: 50000 # max stored PII tokens per tenant
token-retention-days: 30 # auto-expire after N days

Embedded PII Scanner (In-Process, No Third-Party Required)

For broader filter coverage without deploying a sidecar, enable the embedded PII scanner. It runs entirely in-process (Apache-2.0 licensed) with sub-millisecond per-call latency, no network calls, no API keys, and no external service to operate.

dvara:
llm-gateway:
guardrail:
embedded:
enabled: true # opt-in

That's the only configuration required. When enabled, the embedded scanner runs alongside the built-in regex detector as a complementary layer. The default policy enables 14 filter types — the deterministic filters the regex detector does not already cover — so the two layers don't both flag the same entity:

CategoryFilters
FinancialBANK_ROUTING_NUMBER, BITCOIN_ADDRESS, CURRENCY
ContactPHONE_NUMBER_EXTENSION
Network / deviceMAC_ADDRESS, URL
AddressesSTREET_ADDRESS, ZIP_CODE, STATE_ABBREVIATION
Dates & demographicsDATE, AGE
Vehicle / shippingVIN, TRACKING_NUMBER
PHIPHYSICIAN_NAME

Regex-covered types are excluded from the defaultSSN, PASSPORT_NUMBER, DRIVERS_LICENSE_NUMBER, CREDIT_CARD, IBAN_CODE, PHONE_NUMBER, EMAIL_ADDRESS, IP_ADDRESS are already handled (with checksums) by the always-on regex detector, so the embedded scanner leaves them alone to avoid double-detection. If you run the embedded scanner without the regex PII detector, opt them back in via embedded.enabled-filters.

Eight further filters ship in the catalog but are opt-in per tenant because they match against dictionaries (names, cities, counties, states, hospitals, medical conditions) that collide with ordinary English on free-form chat workloads — too many false positives to auto-enable: FIRST_NAME, SURNAME, LOCATION_CITY, LOCATION_STATE, LOCATION_COUNTY, HOSPITAL, MEDICAL_CONDITION, IDENTIFIER (the last over-matches long digit sequences without dictionary tuning).

Per-tenant filter restriction is editable from the Built-in PII Filters tab in the DVARA Flightdeck tenant form, or by setting the tenant metadata key embedded.enabled-filters to a comma-separated list of filter-type names.

Limitations. The embedded scanner is best-effort pattern matching with no Luhn validation. It can mis-classify long digit sequences (e.g. classify a credit card number as a drivers license number depending on filter ordering). For production credit card and NPI detection, rely on the always-on built-in regex detector (which Luhn-validates). Use the embedded scanner as an additive layer for the broader filter coverage it provides (MAC address, IBAN, bitcoin, VIN, etc.) that the curated regex set doesn't include.

Because the default embedded filter set is complementary (non-overlapping with the regex detector), a given entity is caught by exactly one layer — no duplicate detections/audits for the default configuration. (If you opt the regex-covered types back in, both layers may flag the same match; that's the trade-off for the extra coverage.)

Redact instead of flag — the embedded scanner in the PII pipeline

By default the embedded scanner is a guardrail-pipeline layer: its hits are governance detections (FLAG / BLOCK / LOG), but the matched values are not redacted — a detected MAC address or street address is flagged yet still forwarded to the upstream model. To make those broad structured-PII hits tokenize/redact like regular PII, promote the embedded scanner into the PII pipeline instead:

dvara:
llm-gateway:
pii:
embedded:
enabled: true # the embedded scanner becomes a PII detector (redact/tokenize)

With pii.embedded.enabled: true, the embedded detector joins the regex (and optional Presidio) detectors in the same composite: matches are deduplicated by confidence, tokenized/redacted, and eligible for the detokenize round-trip. Only redactable filter types are mapped:

  • Regex-covered structured PII (SSN, CREDIT_CARD, EMAIL_ADDRESS, …) → the matching first-class entity type. (The composite dedups the overlap with the regex detector, so you don't need the complementary-default exclusion here.)
  • Names (FIRST_NAME, SURNAME, PHYSICIAN_NAME) → PERSON_NAME.
  • Broad structured PII with no first-class type (MAC_ADDRESS, ZIP_CODE, BANK_ROUTING_NUMBER, VIN, BITCOIN_ADDRESS, TRACKING_NUMBER, STREET_ADDRESS, location) → CUSTOM, with the specific kind preserved in the detection label (e.g. mac_address).
  • Non-redactable content signals (CURRENCY, AGE, DATE, URL, STATE_ABBREVIATION, MEDICAL_CONDITION, PHONE_NUMBER_EXTENSION) are not mapped — redacting a currency amount or URL would damage the prompt, so these stay flag-only in the guardrail pipeline.

The default PII-pipeline filter set is the complementary structured-PII types (MAC_ADDRESS, ZIP_CODE, BANK_ROUTING_NUMBER, VIN, BITCOIN_ADDRESS, TRACKING_NUMBER, STREET_ADDRESS); override it globally with dvara.llm-gateway.pii.embedded.filters. This is a global setting — per-tenant filter selection remains a guardrail-pipeline capability (embedded.enabled-filters), because the PII scan path is tenant-agnostic.

Pick one pipeline. Enabling both guardrail.embedded.enabled and pii.embedded.enabled makes the same entity get flagged (guardrail) and redacted (PII) — a double detection. The gateway logs a startup WARN if both are on. Choose the guardrail path when you want to observe/block, or the PII path when you want to redact.

Presidio Integration — layered NER (separate pod)

For industrial-grade PII detection using NER models, run Microsoft Presidio as a separate pod and point the gateway at it. Setting a non-blank presidio.endpoint alone enables layered regex + NER — you no longer need provider: presidio (it's kept as an explicit alias). An endpoint is the single toggle:

dvara:
llm-gateway:
pii:
presidio:
endpoint: http://presidio-analyzer:3000/analyze # endpoint alone enables layered NER
language: en # analysis language
score-threshold: 0.5 # minimum confidence
timeout-seconds: 5 # HTTP timeout
cache-max-size: 1000 # LRU cache entries (0 = disabled)
cache-ttl-seconds: 300 # cache TTL

The DVARA Helm chart ships an optional in-cluster Presidio pod (piiNer.enabled=true, default off) that deploys the analyzer and auto-wires this endpoint — the one Helm value that turns on layered detection. The pod's default image is DVARA's GLiNER-configured Presidio build (gliner_multi_pii-v1, Apache-2.0) for zero-shot PII accuracy on unstructured PII (names / locations / organisations); the model is baked into the image so pods start without a model download. The gateway's /analyze contract is identical whether the pod runs GLiNER or the stock spaCy analyzer — set piiNer.image.repository to mcr.microsoft.com/presidio-analyzer to fall back to spaCy.

With an endpoint set, the gateway merges results from the built-in regex detector (16 patterns + checksum/format validation) and Presidio (NER-based PERSON, LOCATION, ORGANIZATION, and more). Overlapping detections are deduplicated, keeping the higher-confidence result. The startup log states the active posture (regex or regex+presidio).

Detection layers at a glance. Regex (always-on, checksum-validated structured PII) → Presidio NER (optional pod, unstructured PII like names/locations, same tokenize/redact path) → Embedded scanner (optional in-process, rule-based broad patterns; runs either in the guardrail layer as flag-only, guardrail.embedded.enabled, or in the PII pipeline as redact/tokenize, pii.embedded.enabled — pick one). Layered NER is not on by default — the default is regex-only.

PropertyDefaultDescription
dvara.llm-gateway.pii.providerregexDetection provider alias. A non-blank presidio.endpoint enables layered NER regardless of this value
dvara.llm-gateway.pii.auto-detokenize-responsefalseOpt-in transparent round-trip — restore request-minted tokens in the response (non-streaming)
dvara.llm-gateway.pii.presidio.endpointPresidio analyzer endpoint URL — the single toggle for layered NER
dvara.llm-gateway.pii.presidio.languageenPresidio analysis language
dvara.llm-gateway.pii.presidio.score-threshold0.5Minimum Presidio confidence score
dvara.llm-gateway.pii.presidio.timeout-seconds5HTTP call timeout
dvara.llm-gateway.pii.presidio.cache-max-size1000LRU cache max entries (0 = disabled)
dvara.llm-gateway.pii.presidio.cache-ttl-seconds300Cache entry TTL

Presidio is fail-open: if the sidecar is unreachable, detection falls back to regex-only results. Cached results use SHA-256 of the input text as cache key; delegate failures are cached with a 30-second TTL to prevent thundering herd.

Per-Tenant Configuration

The fastest way to override PII behavior for a tenant is the PII & DLP tab in the DVARA Flightdeck tenant form (Tenants → Edit). Form-based controls cover pii.enabled, pii.action, and pii.scan-responses. Updates emit a TENANT_METADATA_UPDATED audit event with a diff.

For Terraform, CI/CD, and other programmatic tooling, set the same metadata keys via the Automation API:

curl -X PUT http://localhost:8090/v1/admin/tenants/acme-corp \
-H "Content-Type: application/json" \
-d '{
"metadata": {
"pii.enabled": "true",
"pii.action": "REDACT",
"pii.scan-responses": "true",
"pii.custom-patterns": {
"employee_id": "EMP-\\d{6}",
"internal_project": "PROJ-[A-Z]{2,4}-\\d{4}"
}
}
}'

pii.custom-patterns is currently API-only — the UI form does not yet expose a custom-pattern editor.

pii.custom-patterns must be a nested object, not a stringified JSON value

The value of pii.custom-patterns must be a JSON object (label → regex map), as shown above. Passing it as a stringified JSON value (e.g. "pii.custom-patterns": "{\"employee_id\": \"EMP-\\d{6}\"}") silently fails — the gateway's tenant-config resolver checks the value with instanceof Map, the string fails the check, custom patterns are dropped, and no error is logged. The tenant simply gets zero custom-pattern matches.

Metadata KeyValuesDescription
pii.enabledtrue / falseOverride global PII detection
pii.actionBLOCK / REDACT / LOGOverride default action
pii.scan-responsestrue / falseOverride response scanning
pii.custom-patternsJSON object (label → regex)Add custom regex patterns. Must be a nested object, not a stringified JSON value.

Custom Patterns

Custom patterns are specified as a JSON object where keys are labels and values are regex strings. They are detected as CUSTOM entity type:

{
"employee_id": "EMP-\\d{6}",
"internal_project": "PROJ-[A-Z]{2,4}-\\d{4}"
}

Admin API

Detokenize

Restore original PII values from redacted text:

curl -X POST http://localhost:8090/v1/admin/pii/detokenize \
-H "Content-Type: application/json" \
-d '{
"text": "Contact {{PII_EMAIL_a1b2c3d4}} about account",
"tenant_id": "acme-corp"
}'

Response:

{
"text": "Contact user@example.com about account"
}

Requires owner or policy-admin role.

Purge Tokens

Remove all stored PII tokens for a tenant (irreversible):

curl -X DELETE http://localhost:8090/v1/admin/pii/tokens/acme-corp

Response:

{
"tenant_id": "acme-corp",
"tokens_removed": 1542
}

Requires owner role.

Audit Trail

All PII events are written to the audit trail. The audit payload includes entity types and counts but never includes the actual PII values.

LLM Traffic

Event TypeWhen
PII_DETECTEDPII found in request (action: LOG or BLOCK)
PII_REDACTEDPII redacted from request (action: REDACT)
PII_OUTPUT_LEAKPII detected in LLM response

MCP Traffic

Event TypeWhen
MCP_PII_DETECTEDPII found in tool call arguments (action: LOG or BLOCK)
MCP_PII_REDACTEDPII redacted from tool call arguments (action: REDACT)
MCP_PII_OUTPUT_LEAKPII detected in MCP server response

MCP PII audit events include server_id, tool_name, entity_count, entity_types, source (request / response), action, and trace_id — enough for auditors to filter PII activity by tool, by tenant, or by direction, and to correlate against the gateway trace for the originating LLM turn.

Example audit event payload (LLM PII path — the MCP path adds server_id / tool_name / action on top):

{
"eventType": "PII_REDACTED",
"tenantId": "acme-corp",
"payload": {
"source": "request",
"entity_count": 2,
"entity_types": "EMAIL, SSN",
"entity_type_counts": {"EMAIL": 1, "SSN": 1}
}
}

A few notes on the wire format that aren't obvious from a single example:

  • tenantId is on the envelope, not in payload. Filter audit-event queries by the envelope field, not by a payload field.
  • entity_types is a comma-separated string (Java List.toString().joining(", ")), not a JSON array. Receivers parsing as an array would fail.
  • entity_type_counts is the per-type breakdown — useful for dashboards that want to plot email vs ssn vs credit_card over time without re-parsing the string above.
  • LLM PII payloads do not include an action field — the event type itself (PII_DETECTED vs PII_REDACTED) carries that signal. The MCP PII path does include action since the same MCP_PII_DETECTED event type is used for both LOG and BLOCK actions.

Access control

PII admin operations are gated by platform role — there are no granular PII permission scopes; access follows DVARA's standard six-role model.

OperationRoles
Detokenize (POST /v1/admin/pii/detokenize)owner, policy-admin
Purge a tenant's PII tokens (DELETE /v1/admin/pii/tokens/{tenantId})owner
Configure per-tenant PII settingsowner / policy-admin (Console); tenant admin (Portal → Data Protection)
View PII audit eventsany admin role (results scoped to the caller's tenant)

Streaming PII Enforcement

When stream=true, the gateway wraps the SSE chunk iterator with buffered scanning that detects and redacts PII in-flight before forwarding to the client.

How it works:

  1. Text deltas accumulate in a rolling buffer
  2. When the buffer exceeds streaming-scan-window-size (default 256 chars), the safe region is scanned
  3. An overlap margin (default 64 chars) catches PII spanning chunk boundaries
  4. REDACT: PII tokens replaced in buffered text before forwarding
  5. BLOCK: Stream terminates with finishReason=content_filter
  6. LOG: Stream continues, summary audit event at end

Configuration:

PropertyDefaultDescription
dvara.llm-gateway.pii.scan-streaming-responsestrueEnable PII scanning on streaming responses
dvara.llm-gateway.pii.streaming-scan-window-size256Chars buffered before scan trigger
dvara.llm-gateway.pii.streaming-overlap-margin64Chars retained between windows for boundary detection

Per-tenant override: set pii.scan-streaming-responses in the tenant metadata.

Security Considerations

  • PII tokens are encrypted at rest using AES-256-GCM with the configured token-encryption-password
  • Token storage is durable — encrypted at rest in PostgreSQL, tenant-scoped, and shared across replicas, so tokens survive gateway restarts and detokenize correctly on any pod
  • Audit events never contain the actual PII values, only entity types and counts
  • The BLOCK action rejects the entire request — no partial content is forwarded
  • Cache stripping always uses REDACT behavior regardless of the tenant's configured action