PII Detection Before It Reaches a Provider
5 min read · updated August 3, 2026
The moment you accept free text and forward it to a third party, you have taken on a filtering problem. Most implementations are a list of regular expressions, which is the right first layer and the wrong whole answer — and the difference shows up as both false positives that mangle real text and false negatives that ship a card number.
This is engineering guidance rather than legal advice: detection is a control, and which categories you are obliged to control is a question for whoever advises you on the regime you are under.
The two ways a detector fails
A sixteen-digit run matches a credit card pattern. So does an order number, a session identifier, a timestamp with the separators stripped, and a row of data pasted out of a spreadsheet. Redact all of them and the model receives text with holes in it, answers worse, and your users learn to work around the filter — which is the failure mode nobody measures and everybody eventually has.
The other direction is worse. A card number written with spaces, a national identifier your patterns do not cover because it belongs to a country you did not think about, a name in a script your name-matcher was not built for, or personal information stated in ordinary prose with no format at all — none of these match anything, and all of them are the data you were filtering for.
Both failures come from the same root: a pattern is a hypothesis, and the code treats it as a conclusion. Where the identifier carries a check digit, you can do better than a hypothesis.
Check digits: verify instead of guess
Many structured identifiers are designed to be self-validating, because they were designed for humans to transcribe. A pattern match followed by an arithmetic check turns a broad guess into a narrow verified match, and the cost is a few lines.
Payment card numbers use the Luhn algorithm. Roughly one in ten random digit strings of the right length will pass it by chance, so this cuts the false-positive rate by about an order of magnitude on its own:
// Luhn — payment card numbers, and several national ids.
function luhnValid(digits) {
let sum = 0;
let alt = false;
for (let i = digits.length - 1; i >= 0; i--) {
let n = digits.charCodeAt(i) - 48;
if (n < 0 || n > 9) return false;
if (alt) {
n *= 2;
if (n > 9) n -= 9;
}
sum += n;
alt = !alt;
}
return digits.length > 0 && sum % 10 === 0;
}IBANs use a mod-97 check over the rearranged, letter-expanded string. This one is close to decisive: an arbitrary string passing it is roughly a one-in-ninety-seven event, so a match is almost always a real account number.
// IBAN — ISO 13616 check: move the first four chars to the end,
// map letters to numbers (A=10 ... Z=35), and require mod 97 === 1.
function ibanValid(raw) {
const s = raw.replace(/[\s-]/g, "").toUpperCase();
if (!/^[A-Z]{2}[0-9]{2}[A-Z0-9]{10,30}$/.test(s)) return false;
const moved = s.slice(4) + s.slice(0, 4);
let rem = 0;
for (const ch of moved) {
const code = ch.charCodeAt(0);
const part = code >= 65 ? String(code - 55) : String(code - 48);
for (const d of part) rem = (rem * 10 + (d.charCodeAt(0) - 48)) % 97;
}
return rem === 1;
}The same idea covers more than these two. Several national identity and tax numbers, ISBNs, VAT numbers in some member states, and various healthcare identifiers all carry a modulus check. Before you write a pattern for an identifier, spend five minutes finding out whether it validates — and if it does, never ship the pattern without the check.
A layered pipeline
Structure the detector as ordered layers, cheapest first, each one able to reject rather than only to accept:
text
│
├─ 1. candidate scan cheap patterns, deliberately over-broad
│ output: [{start, end, type, raw}]
│
├─ 2. structural check check digit / modulus / length / prefix
│ drops most false candidates outright
│
├─ 3. context score look at the +/- 40 chars around the span:
│ labels ("card", "iban", "dob"), separators,
│ surrounding sentence shape
│
├─ 4. classifier pass optional NER for names, addresses, orgs —
│ the unstructured classes patterns cannot reach
│
└─ 5. policy decision per type: block | redact | pseudonymise | allow
plus: emit a counter, never the matched valueTwo design notes that matter more than the layer list. Layer five emits counts by type and never the value it found — a detector that logs its hits is a system that concentrates every identifier in your codebase into one log file. And every layer records spans rather than rewriting text, so the decision about what to do with a span is taken once, at the end, by policy rather than by whichever regex ran first.
Context beats pattern for the rest
For identifiers with no check digit, the useful signal is nearby text. A nine-digit number is nothing; a nine-digit number preceded by “SSN:” is something. Implement this as a score rather than a rule: proximity to a label term, the presence of a plausible separator pattern, whether the span sits in a sentence or in what looks like tabular paste, and whether other detections cluster around it. Personal data arrives in clumps, and a date of birth next to a verified card number is far more likely to be a date of birth.
Names, addresses and organisations need a model rather than a pattern, and this is where a small named-entity recogniser earns its keep. Be aware of what you are buying: recognisers are markedly less reliable on names outside the distribution they were trained on, which means a detector tuned on one population will under-protect everybody else. That is a fairness problem as much as a compliance one, and it is a reason to prefer designs that do not depend on catching every name.
What detection cannot do
- Prose. “My wife was diagnosed last Thursday” contains health data about an identifiable person and matches nothing. No detector solves this; only surface design and retention policy do.
- Quasi-identifiers. Postcode, birth date and sex together can identify a person even though no field is identifying alone. Detection works on fields; identifiability is a property of the combination.
- Attachments and images. If your product accepts files, the text pipeline never sees the passport photo. Decide deliberately whether that path is filtered at all.
- The model’s own output. Detection on the way in does nothing about a completion that repeats back or infers something sensitive. If you filter one direction only, know which one and say so.
None of this argues against detection. It argues for treating it as one control among several, sized honestly — the layer that catches structured identifiers reliably and unstructured ones sometimes, sitting underneath decisions about what you collect in the first place.