Skip to main content

Detect PCI and PII in prompts

AI Gateway v2 uses Microsoft Presidio to scan request text for personally identifiable information (PII) and payment data before the request reaches its provider. Turn scanning on under ai-gateway-v2.scanning in your platform values. The chart then deploys Presidio and a scanning adapter next to the gateway. You can combine scanning with prompt injection screening.

Turn on scanning​

The scanning adapter serves the gateway over TLS. It needs a cert-manager issuer for its serving certificate and a Secret, in the release namespace, holding that issuer's CA. Set them once under global.webhooks, the same pair the platform's other webhooks use:

values.yaml
global:
webhooks:
issuerRef:
name: <WEBHOOK_CLUSTER_ISSUER>
kind: ClusterIssuer
caBundleSecret: <WEBHOOK_CA_BUNDLE_SECRET>

ai-gateway-v2:
scanning:
enabled: true

The chart refuses to render with scanning on and no issuer or CA bundle. To give the adapter its own pair, set ai-gateway-v2.scanning.tls.issuerRef and ai-gateway-v2.scanning.tls.caBundleSecret.

Presidio loads its language model when it first starts, which takes a few minutes on a cold node. With the default fail-closed settings, the gateway refuses requests until Presidio passes its warm-up probe:

  • Before the adapter is ready, requests fail with 503 and Gateway policy validation is unavailable, or 504 and Gateway policy validation timed out.
  • After the adapter is ready but before Presidio answers, requests fail with 403 and request denied by governance hook "scanning", including requests that carry no PII.

This happens on a fresh install, on the upgrade that turns scanning on, and when every Presidio replica restarts at once. A rolling update of a running install keeps the old Presidio replicas serving until the new ones are warm.

Choose what happens on a match​

scanning.requestAction sets the action for every detected entity. scanning.entityActions overrides it per entity:

values.yaml
ai-gateway-v2:
scanning:
enabled: true
requestAction: block
entityActions:
- entity: EMAIL_ADDRESS
action: redact
placeholder: '[EMAIL]'
- entity: DATE_TIME
action: log-only
ActionWhat happens to the request
blockRefused with 403. The prompt never reaches the provider.
redactEach match is replaced with a placeholder, then forwarded.
log-onlyRecorded and forwarded unmodified.

The default requestAction is block. A redacted match without its own placeholder becomes [REDACTED-<ENTITY>], for example [REDACTED-EMAIL_ADDRESS]. Redaction edits the body the gateway forwards, so the provider and any conversation capture see only the redacted text.

Choose which entities to look for​

scanning.entities controls what Presidio detects. When it's empty, the scanner uses this default list:

EntityMatches
CREDIT_CARDCard numbers, checksum-validated
US_SSNUS Social Security numbers
IBAN_CODEInternational bank account numbers
EMAIL_ADDRESSEmail addresses
PHONE_NUMBERPhone numbers, region-aware
IP_ADDRESSIPv4 and IPv6 addresses
US_ITINUS taxpayer identification numbers
UK_NHSUK NHS numbers
PERSONPersonal names, from the language model
LOCATIONPlace names, from the language model
DATE_TIMEDates and times, from the language model

Setting entities replaces the default list. Country-specific entities such as IN_AADHAAR and AU_TFN are detected only when you list them. Editing the list rolls the adapter, and an entity name Presidio doesn't support fails the rollout. The full supported list is published in the Presidio documentation.

Add your own recognizers​

To detect identifiers specific to your organization, add pattern recognizers under scanning.presidio.recognizers. Each one names a new entity in supportedEntity, which the scanner then always detects:

values.yaml
ai-gateway-v2:
scanning:
presidio:
recognizers:
- name: employee-id
supportedEntity: EMPLOYEE_ID
patterns:
- name: employee-id-pattern
regex: 'EMP-[0-9]{6}'
score: 80
contextWords:
- employee

Patterns use RE2 syntax. A recognizer can use a denyList of literal strings in place of, or alongside, patterns. A regex that doesn't compile fails the render. Editing the recognizers rolls the Presidio pods.

Exempt known strings​

scanning.allowlist lists strings Presidio never reports, such as a support address that appears in every prompt. Set scanning.allowlistMatch to regex to treat the entries as regular expressions; the default is exact.

Tune the confidence threshold​

scanning.presidio.scoreThreshold sets the minimum detection confidence from 0 to 100 and defaults to 50. Test representative traffic with requestAction: log-only, then adjust the threshold and entity list to balance false positives and false negatives. For predictable enforcement, select the structured identifiers your data-handling policy requires. Presidio analyzes English (en) only.

Decide how failures behave​

scanning.ner.failureAction decides what the adapter does when Presidio is unreachable, slow, or behind an open circuit breaker:

SettingBehavior
fail-closed (default)Refuse the request
fail-openForward the request unscanned and log a warning

Use fail-closed in production and fail-open only during rollout or incident response. With fail-open, requests that arrive while Presidio warms up are forwarded unscanned.

A circuit breaker applies failureAction without calling an unhealthy Presidio:

values.yaml
ai-gateway-v2:
scanning:
ner:
failureAction: fail-closed
circuitBreaker:
failureThreshold: 5
resetTimeout: 30s

Size the scanner​

scanning.ner.timeout limits each Presidio call and defaults to 5s. scanning.hook.timeout is the budget the gateway gives the adapter for the whole scan. Left empty, it's scanning.ner.timeout plus two seconds, so a slow Presidio surfaces as the adapter's fail-closed 403 rather than a gateway 504. Raise scanning.hook.timeout when large requests regularly need several rounds of Presidio calls under load. An explicit value must be greater than scanning.ner.timeout.

Each Presidio pod runs one analysis worker per whole CPU core in scanning.presidio.resources.limits.cpu, and each worker loads its own language model. Size scanning.presidio.resources.limits.memory at about 1 GiB per worker plus headroom; a lower limit fails the render. The chart derives how many Presidio calls one request may have in flight from scanning.presidio.replicas times the workers per pod, so scale Presidio to raise it.

values.yaml
ai-gateway-v2:
scanning:
presidio:
replicas: 2
maxReplicas: 6
targetCPUUtilization: 75
resources:
requests:
cpu: 500m
memory: 2Gi
limits:
cpu: '2'
memory: 3Gi
adapter:
replicas: 2

Setting scanning.presidio.maxReplicas above 1 renders a HorizontalPodAutoscaler. To let an external scaler such as KEDA own the replica count, set scanning.presidio.autoscaling.mode: External and keep scanning.presidio.replicas at the minimum that scaler maintains.

The adapter is on every request's path, so run at least two replicas. At two or more replicas, the chart creates a PodDisruptionBudget for the adapter and for Presidio.

To watch for queuing, set scanning.adapter.metrics.enabled: true. The adapter then serves Prometheus metrics on port 9090. If stacklok_ai_gateway_presidio_analyze_inflight divided by the total number of Presidio workers exceeds 1, calls are queuing in Presidio.

Cache scan results​

Enable the result cache to avoid scanning repeated text. It stores detection results in Redis or Valkey, keyed by the text and the detection inputs.

values.yaml
ai-gateway-v2:
scanning:
resultCache:
enabled: true
ttl: 24h

The cache uses global.redis unless you set a different backend under scanning.resultCache.redis. If the adapter can't verify a cache entry or reach its integrity secret, it scans the text again.

What read access to the cache discloses​

Cache keys are an unsalted hash of the scanned text, which makes the keyspace a confirmation oracle. Anyone who can read the backing Redis can hash a candidate piece of text, probe for the key, and confirm whether that exact text was scanned by this gateway.

On a hit they learn more than the yes or no. The entry carries the entity types found, the name of the rule that matched, and the byte offsets of each detection, which discloses the position and length of every detected value. If you ship custom recognizers, the rule name tells a reader which of your own detection rules fired.

The cache limits exposure as follows:

  • Entries omit scanned text and matched substrings.
  • Each release uses a separate keyspace, under aigw:ner:v1:<NAMESPACE>:<RELEASE_NAME>-ai-gateway-v2:.
  • Integrity tags prevent a writer from inserting a result that suppresses detection.

Treat read access to the cache as permission to confirm whether specific text passed through the gateway. Leave caching disabled if that disclosure conflicts with your security requirements.

The integrity secret​

By default, the adapter creates the Secret <RELEASE_NAME>-ai-gateway-v2-ner-cache-mac in the release namespace the first time it starts. The Secret holds the integrity key for cached results. The adapter creates it, not the Helm release, so it survives an uninstall or a prune.

To supply your own instead, through External Secrets, sealed secrets, or out-of-band creation, name it in your values:

values.yaml
ai-gateway-v2:
scanning:
resultCache:
macSecret:
name: <SECRET_NAME>
key: mac-secret

The adapter then only reads that Secret. Its value must contain at least 32 bytes of printable text; base64-encoded random data satisfies this requirement. If the Secret is missing, the cache stays off.

To rotate the key, replace the Secret's value and restart the adapter. Existing entries then fail verification and are scanned again.

Time to live and footprint​

ttl defaults to 24 hours. Cache keys include the detection inputs, so changes to recognizers or the Presidio image cause cache misses. Shorten the TTL to reduce the disclosure window and memory use.

While Presidio is mid-rollout, old and new pods answer side by side, so the adapter withholds cache writes until every Presidio pod runs the same detection inputs. Reads continue throughout.

Cache metrics​

With scanning.adapter.metrics.enabled set, the adapter exposes these metrics:

MetricWhat it measures
stacklok_ai_gateway_ner_cache_lookups_totalOne per attempted read, labeled hit or miss
stacklok_ai_gateway_ner_cache_errors_totalBackend or codec faults, labeled by operation and error type
stacklok_ai_gateway_ner_cache_op_duration_secondsBackend round-trip per operation
stacklok_ai_gateway_ner_cache_withheld_writes_totalWrites withheld while Presidio pods disagree on detection inputs

Use the hit rate to measure avoided Presidio calls. Cache errors become misses and trigger a normal Presidio call. A withheld-writes rate that never drops means the cache has gone read-only.

Next steps​

Troubleshooting​

Every request is refused with 403 right after enabling scanning

Presidio is still loading its language model. Check that the Presidio pods have passed their startup probe:

kubectl get pods -n <NAMESPACE> -l app.kubernetes.io/component=presidio

The refusals clear once the pods are ready. If they persist, check the adapter logs for Presidio connection errors:

kubectl logs -n <NAMESPACE> -l app.kubernetes.io/component=scanning-adapter
The cache never serves a hit

Confirm a Redis host resolves, from scanning.resultCache.redis.host or global.redis.host. Then check the adapter logs for a warning about the integrity secret: without permission to read or create it, the adapter runs uncached.