Redact CURP, RFC and CLABE Before They Reach the LLM: A Presidio Tutorial for Mexican AI Agents — Cesar Ayala
← All posts

Redact CURP, RFC and CLABE Before They Reach the LLM: A Presidio Tutorial for Mexican AI Agents

When your AI agent sends user text to an LLM, Mexican PII (CURP, RFC, CLABE, phones) travels with it. For a regulated MX product you should redact it first. Use Microsoft Presidio: add custom CURP/RFC/CLABE recognizers, analyze the text, anonymize to placeholders, and wire it as middleware so raw PII never enters the model context.

The leak you don’t see: your agent ships CURP, RFC and CLABE straight to the model

Picture a support agent for a Mexican fintech. A customer writes: “Hola, mi RFC es XAXX010101000, mi CLABE es 002010077777777771 y no me llega el depósito.” Your agent does the obvious thing — it stuffs that message into the prompt and calls the LLM. The moment it does, the customer’s RFC, CLABE and probably their CURP are now sitting in a third-party model’s context window, outside your perimeter.

For a regulated MX product — fintech, fiscal, salud — that is exactly the thing you often must NOT do. The new LFPDPPP (Ley Federal de Protección de Datos Personales en Posesión de los Particulares, published in the DOF on 2025-03-20) raised the bar for handling Mexican personal data, and “we forwarded the raw text to an external model because it was easier” is not a story you want to tell an auditor.

The fix is one line of architecture: redact the PII first, in front of the model. This layer sits ahead of ANY LLM — Claude included — so the model only ever sees <CURP>, <RFC>, <CLABE> instead of the real values.

Two honest caveats before we touch code. Redaction REDUCES leakage; it does not guarantee zero — recognizers miss things. And I’m an engineer, not a lawyer: for LFPDPPP get actual compliance advice. What I can hand you is the engineering control. We’ll build a Microsoft Presidio-based redaction middleware with custom CURP/RFC/CLABE recognizers and wire it in front of your LLM handler.

Which Mexican identifiers must you catch?

Before writing a single regex, know exactly what you’re hunting. These are the Mexican identifiers your recognizers have to match:

  • CURP (Clave Única de Registro de Población) — 18 characters: 4 letters + 6 digits (birth date) + H/M (sex) + 5 letters + 1 alphanumeric + 1 check digit. Character 18 is a CALCULATED check digit.
  • RFC (Registro Federal de Contribuyentes) — 12 characters for personas morales (companies), 13 for personas físicas (individuals): 3-4 letters + 6 digits + 3 alphanumerics. Those last 3 are the homoclave, which includes a check character.
  • CLABE (Clave Bancaria Estandarizada) — 18 digits. Digit 18 is a mod-10 WEIGHTED control digit (standard weights 3,7,1 repeating — verify against the official Banxico/CLABE spec before you rely on it).
  • Mexican phone numbers — a custom recognizer, since the defaults are tuned for other formats.

On top of those, Presidio ships built-in EMAIL_ADDRESS and PERSON recognizers by default. The catch: Presidio’s defaults are English-tuned and do NOT know CURP, RFC or CLABE out of the box. That’s why we add CUSTOM recognizers — the heart of this tutorial.

CURP18 chars · 4 letters + 6 digits (DOB) + H/M + 5 letters + 1 alnum + check digit
RFC12 (morales) / 13 (físicas) · 3-4 letters + 6 digits + 3 alnum homoclave
CLABE18 digits · digit 18 = mod-10 weighted control digit (verify Banxico spec)
MX phonecustom recognizer (10-digit, optional +52 / 01 prefix)
EMAIL · PERSONPresidio built-ins (English-tuned — test PERSON on MX-Spanish data)

Presidio in 60 seconds: Analyzer finds it, Anonymizer replaces it

Presidio is open-source (MIT), actively released (analyzer around 2.2.x as of 2026, confirm current), and split into two pieces you’ll use together:

  • the ANALYZER (AnalyzerEngine) finds PII — it returns each entity’s type, start/end position, and a confidence score;
  • the ANONYMIZER (AnonymizerEngine) takes those results and replaces the spans.

Install both, plus the spaCy model Presidio’s default NLP engine needs:

pip install presidio-analyzer presidio-anonymizer
python -m spacy download en_core_web_lg

The unit we extend is the PatternRecognizer: it detects an entity from a list of regex Pattern objects, with optional context words and an optional validation function. The flow for the rest of this tutorial: register our MX recognizers, call analyzer.analyze(text=..., language="en"), then feed those results into anonymizer.anonymize(...). See the official Presidio analyzer / custom recognizers docs for the full surface (the docs URL may 301-redirect to the current docs host — it still lands you on the right page).

Build it: custom CURP, RFC and CLABE PatternRecognizers

Here’s the core. For each entity we define a Pattern (name, regex, base score), wrap it in a PatternRecognizer, and add context words so a nearby label like “curp” or “cuenta interbancaria” lifts the score:

from presidio_analyzer import AnalyzerEngine, Pattern, PatternRecognizer

# CURP: 4 letters + 6 digits + H/M + 5 letters + 1 alnum + 1 check char.
# NOTE: this regex is an APPROXIMATION. Position 2 (vowel/X), the state
# code, position 17 (homonymia: 0-9 pre-2000, A-Z post-2000) and the
# numeric check digit at position 18 must be VERIFIED against the official
# RENAPO/SAT CURP spec before you rely on it — same as the CLABE checksum.
curp_pattern = Pattern(
    name="curp",
    regex=r"\b[A-Z][AEIOUX][A-Z]{2}\d{6}[HM][A-Z]{5}[A-Z0-9]\d\b",
    score=0.6,
)
curp_recognizer = PatternRecognizer(
    supported_entity="MX_CURP",
    patterns=[curp_pattern],
    context=["curp", "registro", "poblacion"],
)

# RFC: 3-4 letters + 6 digits + 3 alnum homoclave (personas físicas/morales)
rfc_pattern = Pattern(
    name="rfc",
    regex=r"\b[A-ZÑ&]{3,4}\d{6}[A-Z0-9]{3}\b",
    score=0.5,
)
rfc_recognizer = PatternRecognizer(
    supported_entity="MX_RFC",
    patterns=[rfc_pattern],
    context=["rfc", "fiscal", "contribuyente"],
)

# CLABE: 18 digits
clabe_pattern = Pattern(name="clabe", regex=r"\b\d{18}\b", score=0.4)
clabe_recognizer = PatternRecognizer(
    supported_entity="MX_CLABE",
    patterns=[clabe_pattern],
    context=["clabe", "cuenta", "interbancaria", "deposito"],
)

# Mexican phone: 10 digits, optional +52 / 52 / 01 prefix and separators.
# DELIBERATELY LOOSE — this pattern will also fire on order ids, dates and
# amounts, so it is a top source of false positives. Measure it (and
# consider tightening to exactly 10 national digits) before you trust it.
phone_pattern = Pattern(
    name="mx_phone",
    regex=r"\b(?:\+?52\s?|01\s?)?(?:\d{2,3}[\s-]?){3,4}\b",
    score=0.3,
)
mx_phone_recognizer = PatternRecognizer(
    supported_entity="MX_PHONE",
    patterns=[phone_pattern],
    context=["telefono", "celular", "whatsapp"],
)

Register them on an engine and analyze a sample message:

analyzer = AnalyzerEngine()
for r in (curp_recognizer, rfc_recognizer, clabe_recognizer, mx_phone_recognizer):
    analyzer.registry.add_recognizer(r)

user_text = (
    "Hola, mi RFC es XAXX010101000, mi CLABE es 002010077777777771 "
    "y mi celular es 222-123-4567. No me llega el deposito."
)

results = analyzer.analyze(
    text=user_text,
    entities=["MX_CURP", "MX_RFC", "MX_CLABE", "MX_PHONE",
              "EMAIL_ADDRESS", "PERSON"],
    language="en",
)
for r in results:
    print(r.entity_type, r.start, r.end, round(r.score, 2))

One detail trips people up: language="en". Presidio’s built-in PERSON/EMAIL recognizers are English-tuned, but your regex recognizers are language-agnostic — they fire on Spanish text just fine, because a CLABE looks the same whether the surrounding words are English or Spanish.

  1. Install Presidio + spaCy modelpresidio-analyzer, presidio-anonymizer, en_core_web_lg
  2. Register custom MX recognizersCURP, RFC, CLABE, MX phone as PatternRecognizers
  3. Analyze the user textanalyzer.analyze(...) → list of (type, start, end, score)
  4. Anonymize to placeholdersAnonymizerEngine replaces spans with <CURP>, <RFC>, <CLABE>
  5. Wire as middlewareredact() runs before the prompt reaches any LLM
  6. Test on a representative datasetmeasure false positives + false negatives before trusting it

Cut false positives with a check-digit validation (verify the algorithm)

That CLABE regex (\b\d{18}\b) will match ANY 18-digit string — an order id, a long reference number, noise. To cut those false positives, a PatternRecognizer accepts a validation function, validate_result, that recomputes the control digit. Presidio reads its return value as a tri-state: returning True confirms the match and sets its score to 1.0; returning False rejects it (score 0.0, effectively dropped); returning None leaves the regex base score unchanged. So for our CLABE recognizer (base score 0.4), a confirmed checksum bumps the match to 1.0 — which is exactly what we want.

Here’s the SHAPE — not an authoritative checksum:

from typing import Optional


class ClabeRecognizer(PatternRecognizer):
    def validate_result(self, pattern_text: str) -> Optional[bool]:
        # SHAPE ONLY — VERIFY THE ALGORITHM against the official
        # Banxico/CLABE spec before relying on this. Returning None here
        # (instead of False) would be the more conservative, over-redacting
        # choice: it keeps the regex base score instead of dropping the match.
        if len(pattern_text) != 18 or not pattern_text.isdigit():
            return False
        weights = [3, 7, 1] * 6                      # 18 weights, repeating
        body = pattern_text[:17]
        total = sum((int(d) * w) % 10 for d, w in zip(body, weights))
        control = (10 - (total % 10)) % 10
        return control == int(pattern_text[17])      # compare to digit 18


clabe_recognizer = ClabeRecognizer(
    supported_entity="MX_CLABE",
    patterns=[clabe_pattern],
    context=["clabe", "cuenta", "interbancaria"],
)

Do the same idea for RFC and CURP, whose check characters come from the SAT homoclave / RENAPO CURP algorithms. But read this twice: CONFIRM the exact check-digit algorithm against the official Banxico/CLABE spec (and the SAT/RENAPO spec for RFC/CURP) before you trust it. Do not ship an unverified checksum as if it were authoritative. The trade-off is real — validation kills false positives, but a subtly wrong algorithm introduces false negatives, silently letting real PII through. When in doubt, prefer over-redacting (return None to keep the base score, or loosen validation) to under-redacting.

Anonymize to placeholders before you build the prompt

Now close the loop. Feed the analyzer results into AnonymizerEngine with a replace operator per entity type so each detected span becomes a stable placeholder:

from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig

anonymizer = AnonymizerEngine()
operators = {
    "MX_CURP": OperatorConfig("replace", {"new_value": "<CURP>"}),
    "MX_RFC": OperatorConfig("replace", {"new_value": "<RFC>"}),
    "MX_CLABE": OperatorConfig("replace", {"new_value": "<CLABE>"}),
    "MX_PHONE": OperatorConfig("replace", {"new_value": "<PHONE>"}),
    "EMAIL_ADDRESS": OperatorConfig("replace", {"new_value": "<EMAIL>"}),
    "PERSON": OperatorConfig("replace", {"new_value": "<PERSON>"}),
}

anonymized = anonymizer.anonymize(
    text=user_text,
    analyzer_results=results,
    operators=operators,
)
print(anonymized.text)
# Hola, mi RFC es <RFC>, mi CLABE es <CLABE> y mi celular es <PHONE>. ...

This is irreversible replacement — the model only ever sees placeholders, and that’s the safest default. Reach for it unless you have a concrete reason not to.

If the workflow genuinely needs the real value back in the output — say the agent must echo the customer’s actual CLABE in a confirmation — keep a reversible placeholder→value map on YOUR side and de-anonymize after the model responds. Presidio also supports a keyed encrypt/decrypt operator for this. Rule of thumb: go reversible ONLY when the output truly needs the real value; otherwise stay irreversible.

Wire it as middleware: raw PII never enters the model context

The whole point is that no code path can leak raw PII by accident. So wrap analyze→anonymize into one redact(text) function and call it at the boundary, before the prompt is assembled:

def redact(text: str) -> str:
    results = analyzer.analyze(
        text=text,
        entities=["MX_CURP", "MX_RFC", "MX_CLABE", "MX_PHONE",
                  "EMAIL_ADDRESS", "PERSON"],
        language="en",
    )
    return anonymizer.anonymize(
        text=text, analyzer_results=results, operators=operators
    ).text


def handle_user_message(user_text: str) -> str:
    safe_text = redact(user_text)                 # PII gone BEFORE the prompt
    prompt = f"Customer says: {safe_text}\nHelp them resolve it."
    return call_claude(prompt)                     # any model — sees placeholders only

Pin it at the perimeter: a FastAPI dependency, a middleware layer, or a thin wrapper around your Anthropic client so every request flows through redact() first. The pipeline reads: user text → redact() → placeholdered text → build prompt → Claude (or any model) answers → optional de-anonymize → return to user.

When the downstream model is Claude, pair this with a data-retention-appropriate configuration — redaction and retention config are complementary layers, not substitutes. And the agent on the other side still needs to be grounded and safe; see building an AI chatbot without hallucinations and the broader picture in running an LLM in production. If you’re putting this in front of a real customer channel, the WhatsApp AI agent guide is the exact place this redaction layer earns its keep.

User textraw message with CURP / RFC / CLABE / phone
AnalyzerEnginecustom MX recognizers + built-in EMAIL / PERSON
Detected entitiestype, start, end, score per match
AnonymizerEnginereplace each span with <CURP>, <RFC>, <CLABE>…
Build LLM promptplaceholdered text — no raw PII enters the model
Model answers → optional de-anonymizereversible map on your side only if needed

FAQ: false negatives, Spanish text, performance and reversibility

Does redaction guarantee no leakage? No. Presidio recognizers have BOTH false positives and false negatives. Test each one on a representative dataset before trusting it. Redaction reduces leakage, it does not drive it to zero — layer it with access controls and a retention-appropriate model config.

Will it work on Spanish text? Your regex recognizers are language-agnostic and fire on Spanish without changes. The built-in PERSON and EMAIL recognizers are English-tuned, so test PERSON specifically on Mexican-Spanish names before relying on it.

Can I get the real values back? Only if you keep a reversible placeholder→value map (or the keyed encrypt operator) on your side. Otherwise it’s irreversible — which is the safer default.

Is this enough for LFPDPPP compliance? It’s an engineering control, not legal sign-off. Engineer, not a lawyer — get compliance advice for the 2025 LFPDPPP.

What about latency? Analysis runs locally before the API call, so budget for the spaCy model load (once, at startup) and the per-request analyze time. It’s cheap relative to the LLM round-trip, but measure it.

Redact before the LLM

  • Model sees <CURP>, <RFC>, <CLABE> — never raw values
  • Leakage reduced; PII stays inside your perimeter
  • Lower LFPDPPP exposure (engineering control, not legal sign-off)
  • Reversible only if you keep the map on your side

Send raw PII

  • Model sees the real CURP / RFC / CLABE / phone
  • Raw identifiers live in a third-party context window
  • Higher LFPDPPP exposure for a regulated MX product
  • No recovery once it has left your perimeter

Ship the redaction layer, then test it before you trust it

The arc is small and copy-pasteable: custom CURP/RFC/CLABE recognizers → analyze → anonymize to placeholders → a redact() middleware in front of the LLM. Drop it at the boundary and raw Mexican PII stops entering the model context.

Two non-negotiables before you call it done. First, test every recognizer on a representative dataset and measure both false positives and false negatives — a recognizer you haven’t measured is a guess. Second, verify the check-digit algorithms against the official Banxico/CLABE and SAT/RENAPO specs; do not ship an unverified checksum.

Keep it framed honestly: this is a leakage-reduction layer, not a guarantee. Combine it with access controls and a retention-appropriate model config. And for LFPDPPP and PII handling, talk to compliance — I’m an engineer, not a lawyer. For more on hardening the agents this sits in front of, see securing MCP servers and the rest of the AI agents guides. Worth a read on the same theme: Ploomber on preventing PII leakage with Presidio.