Dev.to Security 🔐 Cybersecurity 👁 0 📖 8 min read

Presidio: detect and redact PII before your prompts reach the LLM

Thursday AI Tool/Workflow. This week's tool: Presidio, the open-source PII detection and anonymization framework that started at Microsoft. You run text through presidio-analyzer to find names, emails, phone numbers, car

Thursday AI Tool/Workflow. This week's tool: Presidio, the open-source PII detection and anonymization framework that started at Microsoft. You run text through presidio-analyzer to find names, emails, phone numbers, card numbers and your own internal IDs, then through presidio-anonymizer to replace, mask, hash or encrypt them, before the text goes into a prompt or a log line.

If you're sending support tickets, emails or chat transcripts to an LLM, the prompt is a copy of your customer's data leaving your system. So is the prompt you log for debugging. Redacting at that one boundary is cheap, and Presidio gives you the building blocks.

Here's how to set it up, with code I ran on the box, the real output, and the gotchas that showed up while testing.

What Presidio is (and who maintains it now)

Presidio has two core text modules:

  • presidio-analyzer finds PII. It runs a set of recognizers: regex patterns with checksums and context words, plus Named Entity Recognition (NER) from an NLP engine (spaCy by default, with Stanza and transformers also supported).
  • presidio-anonymizer takes the analyzer's results and applies operators to each detected span: replace, redact, mask, hash, encrypt, custom or keep. A DeanonymizeEngine reverses encrypt with decrypt.

There's also an image redactor and a structured-data module, but this post sticks to text.

One update you might have missed: Presidio started at Microsoft, and in June 2026 it moved to Data Privacy Stack, an independent, community-governed organization. It's still MIT licensed. github.com/microsoft/presidio now redirects to github.com/data-privacy-stack/presidio, the docs live at presidio.dataprivacystack.org, and new Docker images are published to ghcr.io/data-privacy-stack/presidio-* instead of mcr.microsoft.com.

The current release on PyPI is 2.2.366 for both packages (published October 27, 2026 IST). It needs Python 3.11 to 3.14.

1. Install

python -m venv .venv && source .venv/bin/activate
pip install presidio-analyzer presidio-anonymizer
python -m spacy download en_core_web_lg

en_core_web_lg is the spaCy model the docs use by default. AnalyzerEngine() loads it for English. Install it ahead of time (in your Docker image, for example) so it isn't fetched at runtime.

2. Detect and redact a support ticket

This example adds a custom recognizer for an internal customer ID (CUST- plus six digits), runs detection, and redacts:

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

# 1. Custom recognizer for an internal customer ID like CUST-482913
cust_id = PatternRecognizer(
    supported_entity="CUSTOMER_ID",
    patterns=[Pattern(name="customer_id", regex=r"\bCUST-\d{6}\b", score=0.9)],
    context=["customer", "account"],
)

analyzer = AnalyzerEngine()           # loads spaCy en_core_web_lg by default
analyzer.registry.add_recognizer(cust_id)
anonymizer = AnonymizerEngine()

ticket = (
    "Hi, this is Priya Sharma (customer CUST-482913). "
    "My card 4111 1111 1111 1111 was charged twice. "
    "Email me at [email protected] or call 212-555-0187."
)

# 2. Detect
results = analyzer.analyze(
    text=ticket,
    language="en",
    entities=["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "CREDIT_CARD", "CUSTOMER_ID"],
    score_threshold=0.4,
)
for r in sorted(results, key=lambda r: r.start):
    print(r.entity_type, repr(ticket[r.start:r.end]), round(r.score, 2))

# 3. Redact
redacted = anonymizer.anonymize(
    text=ticket,
    analyzer_results=results,
    operators={
        "DEFAULT": OperatorConfig("replace"),   # -> <ENTITY_TYPE>
        "CREDIT_CARD": OperatorConfig(
            "mask", {"masking_char": "*", "chars_to_mask": 15, "from_end": False}
        ),
    },
)
print(redacted.text)

Real output from my run (presidio 2.2.366, spaCy 3.8.18, en_core_web_lg 3.8.0):

PERSON 'Priya Sharma' 0.85
CUSTOMER_ID 'CUST-482913' 1.0
CREDIT_CARD '4111 1111 1111 1111' 1.0
EMAIL_ADDRESS '[email protected]' 1.0
PHONE_NUMBER '212-555-0187' 0.4
Hi, this is <PERSON> (customer <CUSTOMER_ID>). My card ***************1111 was charged twice. Email me at <EMAIL_ADDRESS> or call <PHONE_NUMBER>.

What each piece does:

  • AnalyzerEngine.analyze(text, language, entities=..., score_threshold=...) returns RecognizerResult objects with entity_type, start, end and score. Passing entities limits detection to the types you care about.
  • PatternRecognizer plus Pattern(name, regex, score) is the quickest way to cover IDs that only exist in your company. My pattern score was 0.9, but the result came back as 1.0 because the word "customer" appeared right before it. That's the context list doing its job.
  • OperatorConfig("replace") with no new_value swaps the span for <ENTITY_TYPE>. The docs say replace is also the default when you pass no operators.
  • mask counts characters, spaces included. With 12 instead of 15 it left ************11 1111 (I tried it), which isn't what you want.

3. Pick the right operator

Operator What you get Use it when
replace <PERSON> or your own new_value Prompts to an LLM. It still reads naturally.
redact The span is removed Logs where the type doesn't matter
mask ***************1111 Support UIs that need the last digits
hash sha256 (default) or sha512 hex Analytics joins without the raw value
encrypt AES ciphertext, reversible with decrypt You need the original back later

Two hash details from the docs, both confirmed in my run:

  • Since 2.2.361, hash uses a random salt per entity by default. "Priya called Priya" produced two different hashes. If you need the same value to map to the same hash (for joins), pass {"salt": ...}. With a shared salt, both "Priya"s got the same hash.
  • The salt has to be at least 16 bytes. A 3-byte salt raised Salt must be at least 16 bytes (128 bits).

4. Reversible: encrypt, then deanonymize

# reversible.py
from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine, DeanonymizeEngine
from presidio_anonymizer.entities import OperatorConfig

KEY = "WmZq4t7w!z%C&F)J"  # demo only: load a 128/192/256-bit key from a secret store

analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()
deanonymizer = DeanonymizeEngine()

text = "Refund approved for Priya Sharma, [email protected]"
results = analyzer.analyze(text=text, language="en",
                           entities=["PERSON", "EMAIL_ADDRESS"])

encrypted = anonymizer.anonymize(
    text=text,
    analyzer_results=results,
    operators={"DEFAULT": OperatorConfig("encrypt", {"key": KEY})},
)
print(encrypted.text)

restored = deanonymizer.deanonymize(
    text=encrypted.text,
    entities=encrypted.items,  # OperatorResult spans from the anonymize step
    operators={"DEFAULT": OperatorConfig("decrypt", {"key": KEY})},
)
print(restored.text)

Output:

Refund approved for nfXyq5C5qA1tmVtKSk1zSCqZeEefUmZoHxGInzcJfxw=, -NMDGybbiHmJ3D5S3WoQGzhsxkZ80OEWenUdaj4BknNzmMonIBfM5N-BPSo3L-fW
Refund approved for Priya Sharma, [email protected]

A key of the wrong length fails immediately: key must be of length 128, 192 or 256 bits.

The catch for LLM round-trips: deanonymize works on character spans, not by searching for tokens. An LLM reply is new text, so encrypted.items won't line up with it. You have to locate each ciphertext token in the reply and build an OperatorResult(start, end, entity_type) for it. I tested that with a reply containing the token and got the name back. Even so, ask yourself whether you need reversibility at all. For a summary, <PERSON> is usually enough.

5. Example workflow: redact before the summarizer and before the log

This is an example, not a production system. Tickets come in, get redacted once, and only the redacted text is logged or sent to the model:

# workflow.py - EXAMPLE: redact support tickets before an LLM summarizer and before logging
import logging
from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig

ENTITIES = ["PERSON", "EMAIL_ADDRESS", "PHONE_NUMBER", "CREDIT_CARD",
            "IP_ADDRESS", "CUSTOMER_ID"]

analyzer = AnalyzerEngine()
analyzer.registry.add_recognizer(PatternRecognizer(
    supported_entity="CUSTOMER_ID",
    patterns=[Pattern("customer_id", r"\bCUST-\d{6}\b", 0.9)],
    context=["customer", "account"],
))
anonymizer = AnonymizerEngine()

def redact(text: str) -> str:
    results = analyzer.analyze(text=text, language="en",
                               entities=ENTITIES, score_threshold=0.4)
    return anonymizer.anonymize(
        text=text,
        analyzer_results=results,
        operators={"DEFAULT": OperatorConfig("replace")},
    ).text

logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
log = logging.getLogger("llm")

def summarize_ticket(ticket: str) -> str:
    safe = redact(ticket)
    prompt = f"Summarize this support ticket in one line:\n{safe}"
    log.info("prompt=%r", prompt)          # only the redacted text is logged
    return call_llm(prompt)                # only the redacted text leaves your system

def call_llm(prompt: str) -> str:          # stand-in for your LLM client
    return "(summary)"

ticket = ("From: Rahul Verma <[email protected]>\n"
          "Account CUST-104233 was double charged. Logged in from 203.0.113.42. "
          "Call me on 212-555-0143.")
summarize_ticket(ticket)

Logged line:

INFO prompt='Summarize this support ticket in one line:\nFrom: <PERSON> <<EMAIL_ADDRESS>>\nAccount <CUSTOMER_ID> was double charged. Logged in from <IP_ADDRESS>. Call me on <PHONE_NUMBER>.'

The model still has everything it needs to summarize ("double charged, wants a call back"), and neither the vendor nor your log store ever sees the customer's details.

Gotchas I hit while testing

  • The score threshold is a trade-off. The US phone number scored exactly 0.4. With score_threshold=0.5 it was dropped, and only PERSON remained. Tune thresholds against your own samples instead of guessing.
  • False negatives are real. These came back with no results at all: a street address ("flat 4B, 12 MG Road, Indiranagar"), a lowercase sign-off ("thanks, ravi") and "Password: hunter2". The docs say it outright: automated detection means there's no guarantee Presidio finds all sensitive information, so you need other protections too.
  • Country-specific recognizers may not be on. In my default install, get_supported_entities() listed 20 entities and no IN_* ones. "my PAN is ABCDE1234F" came back only as LOCATION (NER guessing). After analyzer.registry.add_recognizer(InPanRecognizer()) it was also detected as IN_PAN. The supported entities page lists India recognizers for PAN, Aadhaar, passport, voter ID, GSTIN and vehicle registration. Check which ones your registry actually loads.
  • NER produces false positives too. "+91 98765 43210" was tagged as both PHONE_NUMBER (0.4) and LOCATION (0.85). Without a context word, "CUST-482913" picked up a US_DRIVER_LICENSE hit at 0.01. Pass an explicit entities list and a threshold.
  • Language matters. language="en" uses the English model. For other languages, configure NlpEngineProvider with a model per language and pass supported_languages to AnalyzerEngine.

Checklist before you ship it

  1. Pin presidio-analyzer, presidio-anonymizer and the spaCy model in your image.
  2. Add a PatternRecognizer for every internal ID format (customer, order, employee).
  3. Enable the country recognizers for the countries in your data.
  4. Pass an explicit entities list and a score_threshold you've tested.
  5. Redact once, at the boundary, before both the LLM call and the logger.
  6. Use replace for prompts. Use hash with a managed salt only if you need joins, and encrypt only if you really need the original back.
  7. Keep keys and salts in a secret store, never in code or in the output.
  8. Build a small labelled sample of real (internal) tickets and re-check recall whenever you change models or thresholds.
  9. Treat Presidio as one layer. Retention limits and access control still apply.

References

Where does PII leave your system today: the prompt, the logs, or both?

📰 Read the original article on Dev.to Security

Originally published by Dev.to Security. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.