PII Redaction
Obstruo detects and replaces personally identifiable information (PII) with reversible placeholders before any prompt reaches a language model. The original text never leaves your control plane.
How it works
Every inbound request passes through two detection layers, in order:
%%{init: {'theme': 'base', 'themeVariables': {'primaryColor': '#1E1E26', 'primaryBorderColor': '#8B6FE8', 'primaryTextColor': '#ECEAF6', 'lineColor': '#8B6FE8', 'secondaryColor': '#16161C', 'tertiaryColor': '#16161C', 'mainBkg': '#1E1E26', 'nodeBorder': '#8B6FE8', 'clusterBkg': '#16161C', 'clusterBorder': '#2A2A34', 'titleColor': '#ECEAF6', 'edgeLabelBackground': '#0E0E11', 'fontFamily': 'ui-monospace, SF Mono, Menlo, monospace'}}}%%
flowchart TD
A([User prompt]) --> B
B --> C
C --> D([Redacted prompt → LLM])
B["Layer 1 - Regex\nEmails · phones · credit cards · SSNs · IPs"]
C["Layer 2 - NER\nNames · organisations · locations"]
Detected entities are replaced with tagged placeholders before the prompt reaches the model:
What you send: "My name is Anna Kowalski and my email is anna@example.com"
What LLM sees: "My name is [PERSON] and my email is [EMAIL_ADDRESS]"
After the LLM responds, Obstruo maps the placeholders back to the original values before returning the response to your application. Your code sees the original text throughout - the substitution is invisible at the application layer.
The audit log records which entity types were detected and replaced, but never stores the original PII values.
Configuring redaction
Go to Enforce → Redaction in the panel.
Regex layer
Toggle built-in patterns on or off:
| Pattern | Example match |
|---|---|
| Email address | anna@example.com |
| Phone number (Polish) | +48 123 456 789 |
| Phone number (North American) | (555) 123-4567 |
| Credit card | 4111 1111 1111 1111 |
| Social security number (US) | 123-45-6789 |
| IP address (IPv4 / IPv6) | 192.168.1.1 |
| IBAN | PL61 1090 1014 0000 0712 1981 2874 |
| Polish bank account (NRB) | 26-digit account number |
| PESEL (Polish national ID) | 11-digit number |
| NIP (Polish tax ID) | 10-digit number |
| REGON (business register number) | REGON: 123456785 |
| KRS (National Court Register) | KRS: 0000123456 |
| Person (initial + surname) | J. Kowalski |
| Postal code (Polish / US) | 00-950 / 90210 |
| Date (ISO / Polish / English) | 2026-07-01 / 01.07.2026 / January 15, 2026 |
| URL | https://example.com/page |
Patterns are scoped by your project's primary language - country-specific formats (PESEL, NRB, US SSN, …) are offered where they make sense, so enabling everything everywhere doesn't inflate false positives.
You can add custom patterns using standard regular expressions. A custom rule can't reuse a built-in category (the editor will point you back to the toggle list instead). The Free plan includes up to 3 custom regex rules per project.
The Templates button on the Redaction page offers starter bundles for common compliance scenarios (e.g. General PII, GDPR). Applying one is additive - it pre-selects the relevant patterns without removing anything you've configured.
NER layer
The NER layer uses a named entity recognition model to detect free-text entities that regex cannot catch - person names, company names, locations, and similar. Enable or disable it per project.
Testing redaction
The Redaction page includes a Test panel. Paste sample text and click Test to see exactly which entities would be detected and replaced - without saving any changes or sending a real request.
Use this to validate your configuration before enabling it in production.
Redaction metadata in responses
Every response carries redaction metadata in the obstruo key. The shape differs between streaming and non-streaming.
Non-streaming
The obstruo.redaction block in the response body shows how many entities were detected and by which category:
response = client.chat.completions.create(
model="obstruo-chat",
messages=[{"role": "user", "content": "My email is anna@example.com"}],
)
redaction = response.model_extra.get("obstruo", {}).get("redaction", {})
print(redaction.get("total_entities")) # 1
print(redaction.get("by_category")) # {"EMAIL_ADDRESS": 1}
Streaming
For streaming responses, redaction state is available in the X-Obstruo-Pii-State response header:
| Value | Meaning |
|---|---|
no-pii |
No PII was detected in the prompt |
redacted |
One or more entities were replaced with placeholders |
disabled |
Redaction is turned off for this project |
audit-only |
The key is in Audit-only mode - the request was redacted and logged, but not forwarded to an LLM |
passthrough |
The request hit a binary endpoint (image or audio) - these are proxied without redaction |
stream = client.chat.completions.create(
model="obstruo-chat",
messages=[{"role": "user", "content": "My email is anna@example.com"}],
stream=True,
)
pii_state = stream.response.headers.get("x-obstruo-pii-state")
for chunk in stream:
if chunk.choices and chunk.choices[0].delta.content:
print(chunk.choices[0].delta.content, end="", flush=True)
Next steps
- Configure safety rules: Guardrails
- Review logged requests: Audit Trail
- Panel reference: Panel