DEV Community

Joe Lin for BeGoodTool.com

Posted on

I thought masking PII for AI prompts was a regex problem — the restore step was the real design constraint

A while back I kept running into the same slightly awkward workflow: I wanted an AI model to help rewrite customer support text, summarize an internal note, or clean up something messy from a spreadsheet — but the input also contained real email addresses, phone numbers, IDs, and occasionally card-like numbers that absolutely should not be pasted raw into a third-party prompt.

My first instinct was the obvious one: just redact the sensitive bits with regex. But once I looked at the actual Vue code for this tool, the interesting part was not the detection at all. The hard requirement is reversibility. If an AI reply comes back with placeholders still embedded in the text, you need to restore the exact original values later, without losing track of duplicates, order, or overlaps.

That changes the problem a lot. This is not a "PII detector" so much as a local tokenization system with a downloadable mapping table.

The core idea is a reversible token table, not one-off redaction

The state in the component makes the design pretty explicit:

const inputText = ref("");
const maskedText = ref("");
const mappingList = ref([]); // [{ code, type, value }]
let maskCounter = 1;

const getOrCreateCode = (value, type) => {
  const existing = mappingList.value.find((e) => e.value === value);
  if (existing) return existing.code;
  const code = `[MASK_${maskCounter++}]`;
  mappingList.value.push({ code, type, value });
  return code;
};
Enter fullscreen mode Exit fullscreen mode

That getOrCreateCode() detail matters more than it looks. The tool does not generate a fresh token for every match. If the same email address or phone number appears five times, it reuses the same [MASK_n] code for all of them because it keys the mapping by value.

I actually like that choice. It keeps the output smaller, and it also makes the AI-facing text more semantically consistent: the same real-world entity stays the same placeholder everywhere. If alice@example.com appears in three paragraphs, the restored output does not depend on positional bookkeeping. It just needs one mapping entry.

The tradeoff is that the mapping table is the whole system. If you lose it, the placeholders are just placeholders. The component keeps that table in memory until you export it, which is why the page also has a big warning banner and a JSON download step. Under the hood, the masking pass literally resets the current state and rebuilds everything from scratch:

const runAutoDetect = () => {
  if (!inputText.value.trim()) return;

  mappingList.value = [];
  maskCounter = 1;

  const matches = detectMatches(inputText.value);
  let result = "";
  let cursor = 0;

  matches.forEach((match) => {
    result += inputText.value.slice(cursor, match.start);
    result += getOrCreateCode(match.value, match.type);
    cursor = match.end;
  });

  result += inputText.value.slice(cursor);
  maskedText.value = result;
};
Enter fullscreen mode Exit fullscreen mode

That also reveals one non-obvious product decision: re-running auto-detect blows away the old mapping table and any previous manual additions. In other words, the tool treats each detection run as a new masking session, not as an incremental editor.

Regex detection only works because the matches are prioritized and validated

The detection layer is rule-based, but it is not just a pile of regexes slapped onto a textarea. The component defines typed patterns in a specific priority order:

const patternDefs = [
  { type: "email", regex: /[a-zA-Z0-9.+_-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9.-]+/g },
  {
    type: "ip",
    regex: /\b(?:(?:25[0-5]|2[0-4]\d|1?\d?\d)\.){3}(?:25[0-5]|2[0-4]\d|1?\d?\d)\b/g,
  },
  { type: "ip", regex: /\b(?:[0-9a-fA-F]{1,4}:){2,7}[0-9a-fA-F]{1,4}\b/g },
  { type: "iban", regex: /\b[A-Za-z]{2}\d{2}[A-Za-z0-9]{10,30}\b/g },
  {
    type: "creditCard",
    regex: /\b(?:\d[ -]?){13,19}\b/g,
    validate: luhnCheck,
  },
  { type: "idPassport", regex: /\b\d{3}-\d{2}-\d{4}\b/g },
  { type: "idPassport", regex: /\b[A-Za-z][12]\d{8}\b/g },
  { type: "idPassport", regex: /\b[A-Z]{1,2}\d{6,8}\b/g },
  {
    type: "phone",
    regex: /(?:\+\d{1,3}[-.\s]?)?\(?\d{2,4}\)?[-.\s]?\d{3,4}[-.\s]?\d{3,4}(?:[-.\s]?\d{2,4})?/g,
    minDigits: 7,
  },
];
Enter fullscreen mode Exit fullscreen mode

There are two pragmatic safeguards here that I think are easy to miss.

First, card-like numbers are not accepted on regex shape alone. They also pass through a Luhn check. That is a very practical way to cut down false positives when you are scanning arbitrary text containing long numeric strings.

Second, the tool does not blindly accept overlapping matches. It records every raw match with a priority index, sorts by priority, and then greedily accepts only the first non-overlapping span:

rawMatches.sort((a, b) => a.priority - b.priority || a.start - b.start);

const isOverlapping = (a, b) => a.start < b.end && b.start < a.end;
const accepted = [];
rawMatches.forEach((match) => {
  if (!accepted.some((acc) => isOverlapping(acc, match))) {
    accepted.push(match);
  }
});

accepted.sort((a, b) => a.start - b.start);
return accepted;
Enter fullscreen mode Exit fullscreen mode

That is a small piece of code, but it is the reason the output stays sane. Broad patterns like phone numbers and generic passport-like IDs would otherwise happily collide with more specific patterns. By putting credit cards above phone numbers, for example, the component prefers the more specific interpretation when the spans overlap.

This is also where the implementation feels honest. It does not pretend to understand meaning. It understands formats. That is why the UI explicitly keeps a manual-marking path for names and other unstructured PII.

The restore flow is intentionally plain: JSON in, string replacement out

The privacy-sensitive part of this page is surprisingly unglamorous in a good way. I do not see any API request in the component for detection, export, import, or restore. The mapping table is downloaded with Blob/URL.createObjectURL(), uploaded back with FileReader, and applied with plain string replacement.

Export looks like this:

const payload = {
  version: 1,
  generatedAt: new Date().toISOString(),
  entries: mappingList.value.map(({ code, type, value }) => ({
    code,
    type,
    value,
  })),
};
const blob = new Blob([JSON.stringify(payload, null, 2)], {
  type: "application/json",
});
Enter fullscreen mode Exit fullscreen mode

And restore is just as direct:

const parseMappingJson = (jsonStr) => {
  const parsed = JSON.parse(jsonStr);
  const entries = Array.isArray(parsed) ? parsed : parsed.entries;
  if (!Array.isArray(entries)) throw new Error("invalid mapping format");
  const cleaned = entries
    .filter((e) => e && e.code && typeof e.value !== "undefined")
    .map((e) => ({ code: e.code, type: e.type || "manual", value: e.value }));
  if (!cleaned.length) throw new Error("empty mapping");
  restoreMappingEntries.value = cleaned;
};

const runRestore = () => {
  let result = restoreInputText.value;
  restoreMappingEntries.value.forEach(({ code, value }) => {
    if (!code) return;
    result = result.split(code).join(value);
  });
  restoredText.value = result;
};
Enter fullscreen mode Exit fullscreen mode

That is worth calling out because people often assume a tool like this must be doing something cryptographic behind the scenes. This one is not. It is much simpler than that: replace sensitive spans with stable tokens, keep a plaintext mapping table, and later substitute those tokens back.

For this use case, simplicity is probably a feature. There is less mystery about what happens to the data. The component is basically just local string processing plus a JSON file you are expected to keep yourself.

The honest gotchas are where the tool gets interesting

A few behaviors in the code are exactly the kind of thing I would want to know before trusting a workflow like this.

The first is that manual masking is global, not positional. The component grabs the selected substring, but then replaces it like this:

const selected = maskedText.value.slice(start, end);
const code = getOrCreateCode(selected, "manual");
maskedText.value = maskedText.value.split(selected).join(code);
Enter fullscreen mode Exit fullscreen mode

So if I highlight one occurrence of John and click the manual button, every identical John in the whole textarea gets replaced, not just the one I selected. Sometimes that is exactly what you want. Sometimes it is a surprise.

The second is that "hide values" is only a UI preview state. The table view switches between full text and a shortened preview (ab••••yz style), but the real values still sit in mappingList in memory, and the exported JSON contains the raw data. That is fine, but it means this feature is about shoulder-surfing reduction, not actual cryptographic protection.

The third is the usual regex limitation, and the code more or less admits it. Structured formats like email, IPv4, IBAN, or card numbers are decent fits for rules. Names, addresses, free-form notes, and organization-specific identifiers are not. The tool handles that by giving you a manual fallback, not by pretending regex can solve entity recognition.

And finally, some of the patterns are intentionally broad. \b[A-Z]{1,2}\d{6,8}\b will catch some passport-like identifiers, but broad rules like that can also match things that only look like IDs in the right text. The Luhn check makes the credit-card pattern smarter; the generic ID patterns are still fundamentally shape-based.

That is probably the right compromise for a small client-side utility. I would rather a tool like this be transparent about being rule-based than oversell itself as "AI privacy" magic.

I turned that idea into a small free tool here: PII Mask & Restore Tool. I mostly use it when I want help rewriting or structuring text for an AI prompt without sending the raw personal details in the same message.


Available in other languages

Top comments (0)