Detect PCI and PII in prompts
The AI Gateway 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 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.
For the legacy AI Gateway, see legacy gateway PCI and PII detection.
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 platform namespace, holding
that issuer's CA. Set them once under global.webhooks, the same pair the
platform's other webhooks use:
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 scanning.tls.issuerRef and
scanning.tls.caBundleSecret.
Presidio takes a few minutes to load its language model when it first starts. With the default fail-closed settings, the gateway refuses every request until Presidio is ready.
Choose what happens on a match
scanning.requestAction sets the action for every detected entity.
scanning.entityActions overrides it per entity:
ai-gateway-v2:
scanning:
enabled: true
requestAction: block
entityActions:
- entity: EMAIL_ADDRESS
action: redact
placeholder: '[EMAIL]'
- entity: DATE_TIME
action: log-only
| Action | What happens to the request |
|---|---|
block | Refused with 403 and request denied by governance hook "scanning". The prompt never reaches the provider. |
redact | Each match is replaced with a placeholder, then forwarded. |
log-only | Recorded in the audit event and forwarded unmodified. |
The default requestAction is block. A redacted match without its own
placeholder uses scanning.placeholder. When that's empty too, the match
becomes [REDACTED-<ENTITY>], for example [REDACTED-EMAIL_ADDRESS]. Redaction
edits the body the gateway forwards, so the provider sees only the redacted
text.
To trial a new entity without affecting traffic, set mode: shadow on its
entityActions entry. The adapter then records each detection in its metrics
and logs, but never blocks or redacts for that entity.
Choose which entities to look for
scanning.entities controls what Presidio detects. When it's empty, the scanner
uses this default list:
| Entity | Matches |
|---|---|
CREDIT_CARD | Card numbers, checksum-validated |
US_SSN | US Social Security numbers |
IBAN_CODE | International bank account numbers |
EMAIL_ADDRESS | Email addresses |
PHONE_NUMBER | Phone numbers, region-aware |
IP_ADDRESS | IPv4 and IPv6 addresses |
US_ITIN | US taxpayer identification numbers |
UK_NHS | UK NHS numbers |
PERSON | Personal names, from the language model |
LOCATION | Place names, from the language model |
DATE_TIME | Dates and times, from the language model |
Setting entities replaces the default list. An entity the adapter can't detect
fails the rollout. That includes most of the country-specific entities in the
Presidio documentation,
because the bundled Presidio registry ships their recognizers turned off. To
detect one of those identifiers,
add your own recognizer for it.
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:
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.
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
A scan can fail in four places, and each has its own setting. The defaults refuse the request in every case:
| Setting | When it applies | Default | Open setting |
|---|---|---|---|
scanning.hook.failurePolicy | The gateway can't reach the adapter, times out, or gets an error reply | Fail | Ignore forwards the request unscanned |
scanning.ner.failureAction | The adapter can't reach Presidio, Presidio is slow, or the breaker is open | fail-closed | fail-open forwards the request unscanned |
scanning.extractionFailureAction | The adapter can't extract text from the body, such as invalid JSON | block | continue forwards the request unscanned |
scanning.mutationFailureAction | A match must be redacted but the body can't be rewritten safely | block | continue forwards the request with the match left in |
Use the defaults in production and open settings only during rollout or incident response. Requests forwarded under an open setting reach the provider with unscanned or unredacted content, and each one is logged as a warning.
A refusal from the adapter, including a Presidio failure under fail-closed,
returns 403 with the same message as a block match. When the gateway can't
get an answer from the adapter under Fail, it returns 504 for a timeout,
503 when the adapter is unreachable, and 502 for an invalid reply.
Under scanning.hook.failurePolicy: Ignore, a client can pad a request until
the scan times out and gets forwarded, so keep Fail wherever scanning is a
control you rely on.
A circuit breaker applies ner.failureAction without calling an unhealthy
Presidio:
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. Raise it
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.
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 that scales on CPU.
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. If
stacklok_ai_gateway_presidio_analyze_inflight divided by the total number of
Presidio workers exceeds 1, calls are queuing in Presidio.
Scale Presidio on load with KEDA
Each Presidio worker analyzes one call at a time, so calls can queue while CPU use stays low. To scale on queued work instead of CPU, hand the replica count to KEDA and scale on the in-flight metric the adapter reports.
First, let KEDA own the replica count. With autoscaling.mode: External, the
chart renders no HorizontalPodAutoscaler. Keep replicas at the floor you give
KEDA, because the chart sizes each request's Presidio concurrency from it:
ai-gateway-v2:
scanning:
adapter:
metrics:
enabled: true
presidio:
replicas: 2
autoscaling:
mode: External
The first upgrade that switches to External can drop Presidio to one replica
until KEDA acts, so make the change during a quiet period. Once the upgrade has
removed the chart's HorizontalPodAutoscaler, apply a ScaledObject that targets
the Presidio Deployment:
apiVersion: keda.sh/v1alpha1
kind: ScaledObject
metadata:
name: <RELEASE_NAME>-ai-gateway-v2-presidio
namespace: <NAMESPACE>
spec:
scaleTargetRef:
name: <RELEASE_NAME>-ai-gateway-v2-presidio
minReplicaCount: 2
maxReplicaCount: 6
fallback:
failureThreshold: 3
replicas: 2
triggers:
- type: prometheus
metricType: AverageValue
metadata:
serverAddress: <PROMETHEUS_URL>
query: max_over_time(sum(stacklok_ai_gateway_presidio_analyze_inflight{namespace="<NAMESPACE>",app_kubernetes_io_name="ai-gateway-v2-scanning-adapter"})[5m:1m])
threshold: '8'
ignoreNullValues: 'false'
KEDA keeps sum(in-flight calls) / threshold replicas, so set threshold to
the number of concurrent calls one Presidio pod should carry. Eight fits the
default of two workers per pod. The label that selects the adapter's pods
depends on how your Prometheus scrapes them; match the pod label
app.kubernetes.io/name: ai-gateway-v2-scanning-adapter. The metric appears
only after Presidio's first call, and until then fallback holds the replica
count.
To go back to the chart's autoscaler, delete the ScaledObject first, then
return autoscaling.mode to HPA. If you turn scanning off, delete the
ScaledObject in the same change, because the chart removes the Presidio
Deployment.
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.
ai-gateway-v2:
scanning:
resultCache:
enabled: true
ttl: 24h
The cache uses the global.redis configuration. 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.
- 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 platform namespace the first
time it starts. The Secret holds the integrity key for cached results.
To supply your own instead, through External Secrets, sealed secrets, or out-of-band creation, name it in your values:
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.
Cache metrics
With scanning.adapter.metrics.enabled set, the adapter exposes these metrics:
| Metric | What it measures |
|---|---|
stacklok_ai_gateway_ner_cache_lookups_total | One per attempted read, labeled hit or miss |
stacklok_ai_gateway_ner_cache_errors_total | Backend or codec faults, labeled by operation and error type |
stacklok_ai_gateway_ner_cache_op_duration_seconds | Backend round-trip per operation |
Use the hit rate to measure avoided Presidio calls. Cache errors become misses and trigger a normal Presidio call.
Next steps
- Screen prompts for injection to add adversarial-prompt screening after the scan.
- Forward audit logs to get scan decisions into your security information and event management (SIEM) system.