PromptisePromptise
Docs
GitHub
Promptise - AI Framework LogoPromptise

The foundation layer for agentic intelligence. Build, secure and operate autonomous AI systems with Promptise Foundry.

pip install promptise

[01] Foundry

  • MCPcast
  • The Promptise Agent
  • Reasoning Engine
  • MCP
  • Agent Runtime
  • Prompt Engineering
  • Execution Engine
  • Agent Identity

[02] Resources

  • Documentation
  • GitHub
  • Guides
  • Learning Paths
  • Questions

[03] Company

  • About
  • Terms of Service
  • Privacy Policy
  • Cookie Policy
  • Subprocessors

© 2026 Promptise by Manser Ventures. All rights reserved.

Open source · Python

← Guides> Guardrails and security

AI-Based PII Detection and Redaction Tools for LLM Apps

AI-based PII detection and redaction for LLMs: regex, NER, Presidio, cloud DLP and LLM detectors compared, with code to mask prompts, tool output and logs.

Level
Intermediate
Reading time
20 min
Published
Oct 20, 2026
Last updated
Oct 20, 2026
Tested with
Promptise 1.4.3
By
Promptise Team
  • Guardrails
  • AI Governance
  • Observability
  • Tool Calling
Contents · 17 sections↓
  1. 01What is the best AI-based PII detection and redaction tool?
  2. 02How does PII redaction for LLMs work?
  3. 03Which PII detection approach should you use?
  4. 04When should you mask, and when should you pseudonymize?
  5. 05Where should you redact PII in an LLM app?
  6. 06What you need
  7. 07Step 1: Detect and mask PII with Presidio
  8. 08Step 2: Pseudonymize reversibly around a model call
  9. 09Step 3: Scan with the Promptise PII guardrail
  10. 10Step 4: Redact input, tool results and output in an agent
  11. 11Step 5: Keep PII out of logs and traces
  12. 12The complete example
  13. 13Running it in production
  14. 14Common problems and accuracy pitfalls
  15. 15Limitations
  16. 16Frequently asked questions
  17. 17Next steps

An AI-based PII detection and redaction tool finds personal data such as names, emails, card numbers and IDs in text and replaces it before the text reaches a model, a user or a log. This guide is for developers who send user data to LLMs. It compares the approaches (regex, NER models, Presidio, cloud DLP and LLM-based detectors), explains masking versus reversible pseudonymization, and shows where to redact. You will build a pipeline that keeps PII out of prompts, tool results, replies and logs, with real output.

[01]

What is the best AI-based PII detection and redaction tool?

No single tool finds all personal data, so production systems layer them. Use patterns with checksums for structured identifiers (cards, IBANs, national IDs, emails, keys), and a named-entity model for names and addresses. Presidio combines both and is the common open-source starting point. Cloud services (Google Sensitive Data Protection, Amazon Comprehend, Azure AI Language) add managed detection, and LLM-based detectors catch context-dependent data at a higher cost. Then apply redaction at every boundary, not only the prompt.

Approach

Finds well

Misses

Runs

Regex and checksums

Cards, IBANs, IDs, emails, API keys

Names, addresses, anything without a format

In process, microseconds

NER model (spaCy, GLiNER)

Names, places, organizations

Rare names, text unlike its training data

Local, CPU is enough

Presidio

Both of the above, plus context words

Secrets, many non-English formats

Local library

Cloud DLP

Wide catalogs of types, managed models

Data you may not send out

Vendor API

LLM-based detection

Context-dependent data (a diagnosis, a salary)

Consistency; it is slower and costs more

Local model or API

Promptise guardrails

Patterns plus GLiNER names and secrets, applied to agent input, tool results and output

Context-dependent data, formats outside its patterns

Local, inside the agent

Key takeaways:

  • Combine detectors. Patterns catch formats, NER catches names, and neither catches everything.

  • Redact at every boundary. Inputs, tool results, outputs, logs and traces each leak on their own.

  • Pseudonymize what the reply needs back, mask what it never needs. Placeholders such as <PERSON_1> let the model write a useful reply that you restore afterwards.

  • Measure on your own data. Every detector in this guide produced a false positive or a miss on a three-sentence test message.


[02]

How does PII redaction for LLMs work?

AI PII redaction has four steps. Detect finds candidate spans and gives each a type and a score. Decide keeps the spans that matter for your policy: the entity types you treat as personal data, above a minimum score. Transform replaces each span: with a label (<EMAIL_ADDRESS>), a partial mask (***1111), a hash, or a reversible placeholder. Restore is optional: it puts the original values back into the model's reply before the user sees it, for placeholders only.

The steps run at every point where text crosses a trust boundary:

Text
1
2
3
4
5
6
user message ──▶ [redact] ──▶ model ──▶ tool call ──▶ [restore args?] ──▶ tool
                                ▲                                          │
                                │                                          ▼
                                └──────────── [redact tool result] ◀───── result
model reply ──▶ [redact or restore] ──▶ user
every event ──▶ [redact] ──▶ logs, traces, eval datasets, caches, memory

Detectors work in three ways: patterns (regular expressions, often with a checksum such as Luhn for cards or mod-97 for IBANs), named-entity recognition (a trained model labels spans as person, location or organization), and LLM-based detection (a language model finds personal data from context). The next section compares them.

The terms are easy to mix up. Masking or redaction removes the value for good. Pseudonymization replaces it with a token you can map back, so anyone who holds the map can still identify the person. Anonymization means nobody can re-identify the person, which redacting a few fields rarely achieves on its own.


[03]

Which PII detection approach should you use?

Each approach has a clear job and clear limits.

Regex with checksums. The baseline and the fastest. Promptise's PIIDetector has 69 patterns, with Luhn checks for cards and context keywords for ambiguous formats (a passport number must follow "passport"). Patterns miss every name and address, and loose patterns flag order numbers and dates. Use them for every structured identifier and for secrets, which NER models don't cover.

NER models. spaCy's en_core_web_lg (used by Presidio) and GLiNER PII models (used by Promptise's NERDetector and NeMo Guardrails) find names, locations and organizations. They need one model per language, they miss unusual names, and their confidence varies with context. Use them for free text written by people.

Presidio. An open-source framework (MIT license) that started at Microsoft and is now published by Data Privacy Stack as it moves to community ownership. Its analyzer combines regex, checksums, context words and an NLP model, and its anonymizer replaces spans with operators: replace, redact, mask, hash, encrypt, keep or a custom function. Encrypt is the reversible one. Its hash operator uses a random salt per entity unless you pass one, so pass a fixed salt if you need to join on hashes. Its own docs say there is "no guarantee that Presidio will find all sensitive information". Its default recognizers don't cover API keys or tokens, and languages other than English need extra models and recognizers.

Cloud DLP.

  • Google Cloud Sensitive Data Protection (formerly Cloud DLP) detects built-in and custom infoTypes and de-identifies them by redaction, replacement, masking, hashing or date shifting. Its deterministic encryption (AES-SIV) and format-preserving encryption (FPE-FFX) are reversible with your key.

  • Amazon Comprehend detects PII in English or Spanish text. Real-time detection takes one document of up to 100 KB, while redaction itself runs as an asynchronous batch job. Amazon Bedrock Guardrails blocks or masks PII in prompts and responses, but its docs state that it does not evaluate tool call arguments or tool results, and that model invocation logs keep the original text.

  • Azure AI Language PII detection works on text (synchronously), conversations and native documents, with configurable redaction.

All three are strong when you already trust that cloud with the data. They are a poor fit when the point is to keep data from leaving your network.

LLM-based detection. NVIDIA's NeMo Curator docs report that LLM-based redaction with Llama-3.1-70B "outperformed Presidio by 26% on core PII categories" on English data. In April 2026 OpenAI released Privacy Filter, an open-weight token classifier (Apache 2.0, about 1.5B parameters with 50M active, eight categories including secrets, 128,000-token context) that runs locally. Its README calls it "a redaction and data minimization aid", not an anonymization guarantee. Run LLM detectors inside your trust boundary: sending raw text to a hosted model so it can find the personal data in it defeats the purpose.

Promptise Foundry. Its scanner combines pattern detectors (69 PII patterns with checksums, 96 credential patterns), an optional GLiNER model for names and custom regex rules, and applies them inside an agent: to the user's input, every tool result and the reply. Compared with Presidio, it adds secrets, the agent wiring and failing closed, while Presidio has more anonymizer operators (hash, encrypt) and more languages through its NLP models. Compared with cloud DLP, everything stays local, but the type catalog is smaller and there is no document or image redaction.


[04]

When should you mask, and when should you pseudonymize?

Method

Example

Reversible

Use it when

Mask or label

<EMAIL_ADDRESS>, ***1111

No

The model and the user never need the value

Hash

a3f9...

No; with a fixed salt or key, the same input gives the same hash

You need to join or count without the value

Pseudonymize

<PERSON_1> plus a map

Yes, with the map

The reply must mention the value, such as a name in a greeting

Encrypt (AES-SIV, FPE)

Ciphertext

Yes, with the key

Data pipelines that re-identify later

Pseudonymize per conversation: the same value gets the same placeholder in every turn, so the model can reason about "<PERSON_1>'s second card". Keep the map (the vault) outside the model path, scoped to one tenant and conversation, encrypted, and deleted when the conversation ends. Restore placeholders only in the final reply to an authorized user, never in logs or tool calls you don't control. A vault is a list of real names next to their placeholders, which makes it the most sensitive store in the system.


[05]

Where should you redact PII in an LLM app?

To redact PII for AI apps reliably, cover every place where text crosses a boundary:

  • User input, before the prompt leaves your network and before it lands in conversation memory or a semantic cache.

  • Retrieved documents and tool results. A CRM lookup or a support ticket can carry more personal data than the user ever typed.

  • Tool call arguments, when a tool writes data outside your control, such as an email or a ticket in a third-party system.

  • Model output. The model repeats what it saw and can invent plausible personal data.

  • Logs, traces and eval datasets. These outlive conversations and are read by more people.

  • Human approval requests. A reviewer approving a refund doesn't need the full card number.


[06]

What you need

  • Python 3.10 or later.

  • pip install "promptise>=1.4.3" presidio-analyzer presidio-anonymizer gliner, then python -m spacy download en_core_web_lg (about 400 MB, used by Presidio).

  • The GLiNER model downloads on first use (about 500 MB, according to the Promptise docs).

  • An OpenAI API key in OPENAI_API_KEY for steps 2 and 4, which use gpt-5-mini. Steps 1, 3 and 5 run offline.

  • About 25 minutes.


[07]

Step 1: Detect and mask PII with Presidio

Start by seeing what a detector actually finds. The script below runs Presidio's analyzer on a support message, adds one custom pattern for internal customer IDs, prints every result with its score, and masks the ones the app cares about.

Pythonpresidio_detect.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
"""Detect and mask PII with Presidio: built-in recognizers plus one custom pattern."""

from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig

TEXT = (
    "Hi, this is Maria Keller from Lyon. My card 4111 1111 1111 1111 was charged twice. "
    "Call me on +1 212-555-0187 or write to maria.keller@example.com. "
    "Customer ID CUST-482913, order 2024-118-5521."
)

analyzer = AnalyzerEngine()  # spaCy en_core_web_lg + regex, checksum and context rules
analyzer.registry.add_recognizer(PatternRecognizer(
    supported_entity="CUSTOMER_ID",
    patterns=[Pattern("customer id", r"\bCUST-\d{6}\b", 0.9)],
))

results = analyzer.analyze(text=TEXT, language="en")
for r in sorted(results, key=lambda r: r.start):
    print(f"{r.entity_type:<17} {r.score:.2f}  {TEXT[r.start:r.end]}")

# Keep only the entity types this app treats as personal data, above a minimum score.
WANTED = {"PERSON", "LOCATION", "CREDIT_CARD", "PHONE_NUMBER", "EMAIL_ADDRESS", "CUSTOMER_ID"}
keep = [r for r in results if r.entity_type in WANTED and r.score >= 0.3]

masked = AnonymizerEngine().anonymize(
    text=TEXT,
    analyzer_results=keep,
    operators={
        "DEFAULT": OperatorConfig("replace"),  # <ENTITY_TYPE>
        "CREDIT_CARD": OperatorConfig("mask", {"masking_char": "*", "chars_to_mask": 15, "from_end": False}),
    },
)
print()
print(masked.text)
Output
PERSON            0.85  Maria Keller
LOCATION          0.85  Lyon
CREDIT_CARD       1.00  4111 1111 1111 1111
PHONE_NUMBER      0.40  +1 212-555-0187
EMAIL_ADDRESS     1.00  maria.keller@example.com
URL               0.50  maria.ke
URL               0.50  example.com
CUSTOMER_ID       0.90  CUST-482913
US_DRIVER_LICENSE 0.01  482913
DATE_TIME         0.85  2024-118-5521

Hi, this is <PERSON> from <LOCATION>. My card ***************1111 was charged twice. Call me on <PHONE_NUMBER> or write to <EMAIL_ADDRESS>. Customer ID <CUSTOMER_ID>, order 2024-118-5521.

Read the result list before the masked text, because it is where accuracy problems show:

  • The order number became a date. 2024-118-5521 scored 0.85 as DATE_TIME. Masking all dates would have destroyed a value the support agent needs.

  • The URL recognizer matched inside the email (maria.ke, because .ke is a country domain). The anonymizer resolves overlapping spans, but the noise shows why you choose entity types on purpose.

  • The phone number scored only 0.40. A threshold of 0.5 would have let it through.

The WANTED set and the 0.3 threshold are the decide step, and they are a policy choice. The card is partially masked with the mask operator, so the last four digits remain for the support conversation.


[08]

Step 2: Pseudonymize reversibly around a model call

Masking alone makes some replies useless: you can't send a support answer that says "Hi <PERSON>". Each distinct value gets a stable placeholder, the model works with placeholders, and your code puts the real values back.

Pythonpseudonymize.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
"""Reversible pseudonymization: the model sees placeholders, the user gets real values back."""

from openai import OpenAI
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig

REVERSIBLE = ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER"]  # the reply may need these back
MASKED = ["CREDIT_CARD"]  # never needed again: mask for good


class Pseudonymizer:
    """Swap each distinct value for a stable placeholder, and keep the map to undo it."""

    def __init__(self):
        self.analyzer = AnalyzerEngine()
        self.anonymizer = AnonymizerEngine()
        self.vault: dict[str, str] = {}  # placeholder -> original; keep it out of logs

    def _placeholder(self, entity: str):
        def replace(value: str) -> str:
            for token, original in self.vault.items():
                if original == value and token.startswith(f"<{entity}_"):
                    return token
            token = f"<{entity}_{sum(t.startswith(f'<{entity}_') for t in self.vault) + 1}>"
            self.vault[token] = value
            return token
        return replace

    def pseudonymize(self, text: str) -> str:
        results = self.analyzer.analyze(text=text, language="en", entities=REVERSIBLE + MASKED,
                                        score_threshold=0.3)
        operators = {e: OperatorConfig("custom", {"lambda": self._placeholder(e)}) for e in REVERSIBLE}
        operators["CREDIT_CARD"] = OperatorConfig("mask", {"masking_char": "*", "chars_to_mask": 15,
                                                          "from_end": False})
        return self.anonymizer.anonymize(text=text, analyzer_results=results, operators=operators).text

    def restore(self, text: str) -> str:
        for token, original in self.vault.items():
            text = text.replace(token, original)
        return text


message = ("Hi, this is Maria Keller. My card 4111 1111 1111 1111 was charged twice. "
           "Please email maria.keller@example.com, and copy my husband Tom Keller.")

p = Pseudonymizer()
safe = p.pseudonymize(message)
print("sent to the model:", safe)

reply = OpenAI().responses.create(
    model="gpt-5-mini",
    instructions="Draft a two-sentence support reply. Keep placeholders like <PERSON_1> exactly as written. Use at most 50 words.",
    input=safe,
).output_text
print("model reply:      ", reply)
print("shown to the user:", p.restore(reply))
Output
sent to the model: Hi, this is <PERSON_2>. My card ***************1111 was charged twice. Please email <EMAIL_ADDRESS_1>, and copy my husband <PERSON_1>.
model reply:       Hi <PERSON_2>, sorry for the double charge on your card ending in 1111 — we’ll investigate this immediately and process a refund if appropriate. We will email <EMAIL_ADDRESS_1> and copy <PERSON_1> with updates within 2 business days.
shown to the user: Hi Maria Keller, sorry for the double charge on your card ending in 1111 — we’ll investigate this immediately and process a refund if appropriate. We will email maria.keller@example.com and copy Tom Keller with updates within 2 business days.

The model never saw a name or an email, kept the placeholders as instructed, and the user got a complete reply. Three details make this safe to use:

  • The card is masked, not pseudonymized. The reply doesn't need the full number, so restoring it would only put it into the output and the logs.

  • The vault is per instance. Create one per conversation and tenant, and never log it.

  • Placeholders are typed and numbered (<PERSON_1>, <PERSON_2>), so the model can tell two people apart. Presidio's anonymizer processes spans from the end of the text, which is why the second name got number 1.


[09]

Step 3: Scan with the Promptise PII guardrail

Promptise Foundry's PromptiseSecurityScanner runs detectors locally and returns a report of every finding. PIIDetector and CredentialDetector are pattern based and need no model. NERDetector adds GLiNER for names and addresses. The same message as step 1, plus a leaked AWS key:

Pythonpromptise_scan.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
"""The Promptise PII guardrail on its own: what each detector finds, and the redacted text."""

import asyncio

from promptise import (
    CredentialDetector, CustomRule, NERDetector, PIIDetector, PromptiseSecurityScanner,
)

TEXT = (
    "Hi, this is Maria Keller from Lyon. My card 4111 1111 1111 1111 was charged twice. "
    "Call me on +1 212-555-0187 or write to maria.keller@example.com. "
    "Customer ID CUST-482913, order 2024-118-5521. Our AWS key is AKIAIOSFODNN7EXAMPLE."
)

scanner = PromptiseSecurityScanner(
    detectors=[
        PIIDetector(),          # 69 regex patterns with checksums: cards, IDs, email, phone (no model)
        CredentialDetector(),   # 96 patterns for API keys, tokens, connection strings (no model)
        NERDetector(labels=["person", "address"], threshold=0.4),  # GLiNER for names and addresses
    ],
    custom_rules=[CustomRule(name="customer_id", pattern=r"\bCUST-\d{6}\b")],
    redact_input=True,          # redact the user's message before the model sees it
)


async def main():
    scanner.warmup()  # loads GLiNER now and fails fast if it is missing
    report = await scanner.scan_text(TEXT, direction="input")
    for f in report.findings:
        print(f"{f.detector:<10} {f.category:<15} {f.confidence:.2f}  {f.matched_text}")
    print()
    print(report.redacted_text)


asyncio.run(main())
Output
pii        credit_card     1.00  4111 1111 1111 1111
pii        aadhaar         1.00  4111 1111 1111
pii        my_number       1.00  4111 1111 1111
pii        email           1.00  maria.keller@example.com
pii        phone           1.00  +1 212-555-0187
pii        phone           1.00  +1 212-555-0187
pii        phone           1.00  024-118-5521
credential aws_access_key  1.00  AKIAIOSFODNN7EXAMPLE
ner        ner_person      0.49  Maria Keller
custom     customer_id     1.00  CUST-482913

Hi, this is [NER_PERSON] from Lyon. My card [CREDIT_CARD] was charged twice. Call me on [PHONE] or write to [EMAIL]. Customer ID [CUSTOMER_ID], order 2[PHONE]. Our AWS key is [AWS_ACCESS_KEY].

What this shows:

  • Secrets are covered. The AWS key (the example key from AWS's docs) was found by CredentialDetector, which Presidio has no recognizer for.

  • Overlapping matches merge into one span. The card also matched two 12-digit national ID patterns, and the merged span takes the label of the longest match, so the text shows one [CREDIT_CARD].

  • A different false positive. The phone pattern matched the last ten digits of the order number and left 2[PHONE]. Presidio misread the same value as a date. Every detector has blind spots, and they differ.

  • NER confidence depends on context. GLiNER scored "Maria Keller" 0.49 in this message, below the default threshold of 0.5. The scanner uses 0.4 for that reason. "Lyon" alone was not labeled an address.

  • The custom rule caught the customer ID, the same job as Presidio's custom recognizer.


[10]

Step 4: Redact input, tool results and output in an agent

An agent adds two leak paths a chat app doesn't have: tool results and tool arguments. Here is a small CRM tool that returns a full customer record:

Pythoncrm_server.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
"""A CRM tool server whose results contain personal data."""

from promptise.mcp.server import MCPServer

server = MCPServer("crm")


@server.tool(read_only_hint=True)
async def get_customer(customer_id: str) -> dict:
    """Look up a customer record by customer ID."""
    return {
        "customer_id": customer_id,
        "name": "Maria Keller",
        "email": "maria.keller@example.com",
        "phone": "+1 212-555-0187",
        "card_on_file": "4111 1111 1111 1111",
        "last_charges": [{"amount": 49.0, "date": "2026-10-14"}, {"amount": 49.0, "date": "2026-10-14"}],
    }


if __name__ == "__main__":
    server.run(transport="stdio")

The agent gets the scanner as guardrails=. redact_input=True redacts the user's message before the model sees it, scan_tool_results=True scans every tool result before the model reads it, and the final reply is redacted by default.

Pythonagent.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
"""A support agent whose model never sees raw personal data: input, tool results and output are redacted."""

import asyncio
import sys

from promptise import (
    CredentialDetector, CustomRule, NERDetector, PIIDetector, PromptiseSecurityScanner, build_agent,
)
from promptise.config import StdioServerSpec

scanner = PromptiseSecurityScanner(
    detectors=[PIIDetector(), CredentialDetector(), NERDetector(labels=["person", "address"], threshold=0.4)],
    # NER misses names inside JSON values, so redact the "name" field of tool results by key.
    # Customer IDs are left alone: the model needs them to call get_customer.
    custom_rules=[CustomRule(name="person_name", pattern=r'(?<="name": ")[^"]+')],
    redact_input=True,        # the user's message
    scan_tool_results=True,   # every tool result, before the model reads it
)                             # the final reply is redacted by default


async def main():
    scanner.warmup()
    agent = await build_agent(
        model="openai:gpt-5-mini",
        servers={"crm": StdioServerSpec(command=sys.executable, args=["crm_server.py"])},
        instructions="You are a support assistant. Look customers up with your tools. Answer in under 60 words.",
        guardrails=scanner,
    )
    try:
        result = await agent.ainvoke({"messages": [{"role": "user", "content":
            "I'm Maria Keller (maria.keller@example.com), customer CUST-482913. "
            "Why was I charged twice? Please confirm the card you have on file."}]})
        for message in result["messages"]:
            print(f"[{message.type}] {message.content or message.tool_calls}\n")
    finally:
        await agent.shutdown()


asyncio.run(main())
Output
[human] I'm [NER_PERSON] ([EMAIL]), customer CUST-482913. Why was I charged twice? Please confirm the card you have on file.

[ai] [{'name': 'get_customer', 'args': {'customer_id': 'CUST-482913'}, 'id': 'call_viP4i4BUGGCyQiYjEM4VfFCh', 'type': 'tool_call'}]

[tool] {"customer_id": "CUST-482913", "name": "[PERSON_NAME]", "email": "[EMAIL]", "phone": "[PHONE]", "card_on_file": "[CREDIT_CARD]", "last_charges": [{"amount": 49.0, "date": "2026-10-14"}, {"amount": 49.0, "date": "2026-10-14"}]}

[ai] Hi [[NER_PERSON]] — I see two $49 charges on 2026-10-14 (appears duplicated). Card on file: [CREDIT_CARD]. I can investigate and issue a refund or open a dispute. Should I proceed with a refund now?

These are the messages the model actually worked with. The user's name and email were redacted on input, the tool result reached the model with its name, email, phone and card replaced, and the reply only shows [CREDIT_CARD]. Redaction kept the JSON valid, so the model still read the record correctly.

The first version of this agent had two bugs worth knowing about, because they are the two most common agent redaction failures:

  • It redacted the customer ID, and the model then called get_customer with the literal string [CUSTOMER_ID]. Never redact an identifier the agent needs for a tool call. Leave it, or pseudonymize it and restore it in tool arguments inside your trust boundary.

  • NER missed the name inside the JSON tool result, so "Maria Keller" reached the model. NER models rely on sentence context, which a JSON value doesn't have, so structured data needs rules by field. The custom rule now redacts the value of every "name" key.

The reply also greets the user as [[NER_PERSON]]. If replies must address people by name, combine this with the pseudonymization from step 2.


[11]

Step 5: Keep PII out of logs and traces

Logs outlive conversations and are read by more people. A logging filter redacts every record, whatever code wrote it:

Pythonlog_redaction.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
"""Keep PII out of application logs: a logging filter that redacts every record."""

import logging

from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine


class RedactPII(logging.Filter):
    def __init__(self, entities: list[str]):
        super().__init__()
        self.analyzer, self.anonymizer, self.entities = AnalyzerEngine(), AnonymizerEngine(), entities

    def filter(self, record: logging.LogRecord) -> bool:
        text = record.getMessage()
        found = self.analyzer.analyze(text=text, language="en", entities=self.entities, score_threshold=0.3)
        record.msg, record.args = self.anonymizer.anonymize(text=text, analyzer_results=found).text, None
        return True


handler = logging.StreamHandler()
handler.addFilter(RedactPII(["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "CREDIT_CARD"]))
log = logging.getLogger("support")
log.addHandler(handler)
log.setLevel(logging.INFO)

log.info("refund requested by %s <%s>, card %s", "Maria Keller", "maria.keller@example.com", "4111 1111 1111 1111")
Output
refund requested by <PERSON> <<EMAIL_ADDRESS>>, card <CREDIT_CARD>

For agent traces, Promptise's observability collector replaces API keys, tokens, card numbers, US social security numbers and email addresses with placeholders by default (redact_sensitive=True). Pattern matching cannot recognize names or addresses, so when traces leave your network, also stop recording tool arguments and results, and prompt text:

Python
from promptise import ObservabilityConfig

observability = ObservabilityConfig(redact_sensitive=True, record_tool_io=False, record_prompts=False)
agent = await build_agent(..., observe=observability)

[12]

The complete example

The examples are separate scripts, one per step, so you can run each one alone:

Text
1
2
3
4
5
6
7
pii-redaction/
├── presidio_detect.py   # step 1: detect, decide, mask with Presidio
├── pseudonymize.py      # step 2: reversible placeholders around a model call
├── promptise_scan.py    # step 3: Promptise scanner report
├── crm_server.py        # step 4: MCP tool that returns personal data
├── agent.py             # step 4: agent with input, tool-result and output redaction
└── log_redaction.py     # step 5: logging filter
>_Terminal
pip install "promptise>=1.4.3" presidio-analyzer presidio-anonymizer gliner
python -m spacy download en_core_web_lg
export OPENAI_API_KEY=sk-...
python presidio_detect.py && python promptise_scan.py && python log_redaction.py   # offline
python pseudonymize.py && python agent.py                                           # gpt-5-mini

[13]

Running it in production

Measure recall on your own data

Label the personal data in a few hundred real messages and tool results, and measure per entity type what the pipeline finds (recall) and flags wrongly (precision). Run the set in CI whenever a detector, threshold or model changes, and weight misses by risk. See testing LLM prompts and agents for the CI setup.

Plan for languages and formats

NER models are trained per language and patterns per country. Test the languages and ID formats your users type, spaced and unspaced, and add a rule for each miss.

Decide what happens when a detector fails

Promptise's scanner fails closed by default: if a detector cannot run, the scan blocks instead of passing text through. Keep that for anything that leaves your network. Call scanner.warmup() at startup, so a missing model fails the deployment instead of the first request. See AI agent guardrails for the failure modes.

Watch latency and cost

Patterns take microseconds. NER on CPU adds milliseconds per message and loads a model of hundreds of megabytes once per process. Cloud DLP adds a network round trip and a fee, and LLM-based detection a full model call. Run the cheap layers on everything and the expensive ones where a rule can't decide.

Secure the vault and the reviewers' view

Store pseudonymization maps encrypted, per tenant, with a time limit, and audit restores. In multi-tenant agents, a vault shared across tenants is a leak waiting to happen. When a human reviews a tool call, Promptise's ApprovalPolicy masks PII and credentials in the arguments the reviewer sees (redact_sensitive, on by default).

Treat it as one control, not the whole answer

Redaction keeps less personal data in prompts, logs and traces, but it is one control among several. You still decide what you collect, how long you keep it and what your model provider may do with it. Some uses of AI are regulated in some places; ask your legal and compliance team what applies to you.


[14]

Common problems and accuracy pitfalls

Symptom

Cause

Fix

Names reach the model

Patterns don't cover names, or NER scored below the threshold

Add an NER detector, tune its threshold on your data

A name inside JSON tool results is not redacted

NER models rely on sentence context, which JSON values lack

Redact structured data by field (key-based rules)

The agent calls a tool with [CUSTOMER_ID]

An identifier the tool needs was redacted

Don't redact lookup keys, or pseudonymize and restore them in tool arguments

Order numbers or dates disappear

A loose phone or date pattern matched them

Choose entity types on purpose, give IDs a prefix, use an allow list (Presidio's allow_list)

Part of a number is left in the text

A pattern matched only part of a value, such as spaced IBAN groups

Test spaced and unspaced formats, add a rule for each

Replies show [NER_PERSON] or <PERSON_1>

Labels flow into the output

Pseudonymize and restore, or tell the model not to address people by name

PII shows up in logs anyway

Only the prompt path is redacted

Add a logging filter and trace redaction

PII appears in the reply that was never in the input

The model invented plausible details

Scan outputs too, not just inputs


[15]

Limitations

  • No detector finds everything. Presidio, Promptise and the cloud services say so in their own docs. Redaction lowers risk; it does not remove it.

  • Context-dependent data is hard. A diagnosis in a clinical note or a salary in a chat message has no format. Only LLM-based detection handles it reasonably, at a cost.

  • Pseudonymized data can still identify people. Anyone with the vault can reverse it, so protect the vault like the original data.

  • Promptise's patterns focus on common formats (69 PII patterns, including government IDs from more than 20 countries, and 96 credential patterns), and its NER is the GLiNER model you load. For images, files and scanned documents, use Presidio's image redactor or a cloud DLP service.

  • These examples are English only. Other languages need their own NER models and test data.


[16]

Frequently asked questions

What is the best AI-based PII detection and redaction tool?

There is no single best tool. Combine pattern detectors with checksums for structured identifiers and an NER model for names and addresses. Presidio is the common open-source choice, Promptise Foundry adds secrets and agent wiring, and Google, AWS and Azure offer managed detection. LLM-based detectors such as OpenAI Privacy Filter catch context-dependent data. Measure any of them on your own data.

How do I prevent PII in prompts sent to an LLM?

Run a detector on the user's message before you build the prompt, and replace what it finds with labels or reversible placeholders. Do the same for retrieved documents and tool results, which often carry more personal data than the user typed. In Promptise, PromptiseSecurityScanner(redact_input=True, scan_tool_results=True) passed as guardrails= does both for an agent.

Can Presidio redact PII before it reaches an LLM?

Yes. Presidio's analyzer finds PII spans in the text, and its anonymizer replaces them with labels, masks, hashes or encrypted values before you send the prompt. Choose the entity types and a minimum score on purpose, add custom recognizers for internal IDs, and test it, because the default recognizers produce false positives such as order numbers read as dates.

What is the difference between masking and pseudonymization?

Masking removes the value for good, for example <EMAIL_ADDRESS> or ***1111. Pseudonymization replaces it with a placeholder such as <PERSON_1> and keeps a map so you can restore it later. Use pseudonymization when the model's reply must mention the value, and masking for everything the reply never needs.

Is it safe to use an LLM to detect PII?

Only if the model runs inside your trust boundary, such as a self-hosted model or an open-weight detector like OpenAI Privacy Filter. Sending raw text to a hosted model to find the personal data exposes that data. LLM detection is also slower and less predictable, so use it as one layer.

Do I need to redact PII in logs and traces?

Yes. Logs and traces keep data longer than conversations, and more people and tools read them. Add a logging filter that redacts every record, and configure your tracing to replace sensitive values or skip tool arguments and prompt text. Promptise redacts keys, cards, social security numbers and emails in traces by default.


[17]

Next steps

  • Add the full guardrail stack to an agent: AI agent guardrails.

  • Understand the concept: what is an LLM guardrail?

  • Compare guardrail frameworks: NeMo Guardrails vs Guardrails AI.

  • Record what an agent did without leaking data: AI agent observability and AI agent audit logs.

  • Keep tenants apart: multi-tenant AI agents.

  • Reference: Promptise security guardrails, Presidio and OpenAI Privacy Filter.

Contents

  1. 01What is the best AI-based PII detection and redaction tool?
  2. 02How does PII redaction for LLMs work?
  3. 03Which PII detection approach should you use?
  4. 04When should you mask, and when should you pseudonymize?
  5. 05Where should you redact PII in an LLM app?
  6. 06What you need
  7. 07Step 1: Detect and mask PII with Presidio
  8. 08Step 2: Pseudonymize reversibly around a model call
  9. 09Step 3: Scan with the Promptise PII guardrail
  10. 10Step 4: Redact input, tool results and output in an agent
  11. 11Step 5: Keep PII out of logs and traces
  12. 12The complete example
  13. 13Running it in production
  14. 14Common problems and accuracy pitfalls
  15. 15Limitations
  16. 16Frequently asked questions
  17. 17Next steps

Learning paths

Want more structure? Paths put guides in order, like a short course.

See the paths →

> Guardrails and security

Related guides

All guides in this topic →
  • // 01Intermediate · 18 minAI Agent Prompt Injection: How to Defend Against ItHow indirect prompt injection reaches AI agents through tool results, and how to defend: untrusted-data prompts, scanners, approval gates, least privilege.
  • // 02Intermediate · 18 minAI Agent Code Execution Sandbox: Run LLM Code SafelyLet an AI agent write and run Python in a Docker sandbox: analyse a CSV, try real escape attempts, set limits, and learn where the sandbox stops.
  • // 03Intermediate · 20 minAutoGen Docker Task Isolation: Secure the Code ExecutorSet up AutoGen Docker task isolation: configure DockerCommandLineCodeExecutor, run one container per task, cut the network, set limits and fix errors.
  • // 04Advanced · 22 minAI Agent Identity: Authenticate Agents to MCP ServersGive your AI agent its own verifiable identity: short-lived JWTs from your IdP, checked by MCP servers with JWKS. Real runs, refresh and honest limits.
  • // 05Advanced · 20 minSPIFFE for AI Agents: Workload Identity Without API KeysUse SPIFFE and SPIRE for AI agent identity: X.509 vs JWT-SVIDs, calling APIs and MCP servers, cloud workload identity compared, with runnable code.
  • // 06Advanced · 18 minMulti-Tenant AI Agents: Keep Each Customer's Data ApartBuild a multi-tenant AI agent in Python: per-user tokens, tenant-scoped MCP tools and isolated sessions, with runs proving one tenant can't read another.

Keep going.

Browse every guide by topic and level, or follow a learning path that puts them in order.

All guidesLearning paths