Skip to the solution
MediumAI Engineering16 min read

How do you handle PII and sensitive data when calling LLM APIs?

315 views

The problem

Question presented to candidate: "We want to use a hosted LLM to draft replies to customer support tickets. The tickets contain names, emails, phone numbers and sometimes card numbers, and legal is nervous. How would you design the system so we can use the model without mishandling personal data?"

What a strong answer should cover:

  • Data minimisation first: send only what the task needs, and map every place the data goes (the provider, your logs and traces, caches, vector stores, eval sets), not just the API call
  • Detection and redaction before the call: regex with validation (such as the Luhn checksum for cards), named-entity recognition, known values from your own database, and typed placeholders the model can still reason about, with a local mapping to rehydrate the reply
  • The limits of redaction: free-text names and context leak, detectors have false positives and negatives, and pseudonymised data is still personal data under GDPR
  • Provider and contract controls: retention and training terms, zero data retention and abuse-monitoring options, BAAs for health data, regional processing, and which features (files, batches, stored responses) keep data longer
  • Your own side and the output side: scrubbed logs with keyed tokens instead of raw values, retention and deletion across every copy, and checking model output before it reaches a user or a log

Clarifying questions expected:

  • Which kinds of data and which jurisdictions (GDPR, HIPAA, PCI DSS for cards)?
  • Does the task actually need the personal data, or only its type (that an email exists, not what it is)?
  • Which provider, and what contract terms or retention arrangements are already in place?

Code / implementation expected: Optional โ€” a redact, call, rehydrate pipeline with typed placeholders, or a data-flow diagram showing where personal data is stored.

  • pii
  • privacy
  • compliance
  • security

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

16 min read ยท 11 sections

Read the code

Redact, call, rehydrate: typed placeholders, Luhn check and keyed log tokens
// Redact PII before an LLM call, keep the mapping local, rehydrate the reply.
// Regex + checksum detectors, a known-names list standing in for NER, typed placeholders,
// keyed HMAC tokens for logs. The model is a stub; nothing leaves this process.
const crypto = require("crypto");

const TICKET = "Hi, this is Maria Lopez. My card 4111 1111 1111 1111 was charged twice for order " +
  "1234 5678 9012 3456. Email me at maria.lopez@example.com or call +49 151 2345 6789. " +
  "My neighbour Tom Becker took the parcel in. Login came from 203.0.113.42.";
const KNOWN_NAMES = ["Maria Lopez"];          // from the CRM record of this customer
const TRUTH = ["Maria Lopez", "4111 1111 1111 1111", "maria.lopez@example.com", "+49 151 2345 6789", "Tom Becker", "203.0.113.42"];
const LOG_KEY = "demo-key-rotate-me";         // in production: from a secrets manager

function luhn(digits) {
  let sum = 0;
  for (let i = 0; i < digits.length; i++) {
    let d = Number(digits[digits.length - 1 - i]);
    if (i % 2 === 1) { d *= 2; if (d > 9) d -= 9; }
    sum += d;
  }
  return sum % 10 === 0;
}

const DETECTORS = [                          // order matters: most specific first
  ["EMAIL", /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, v => true],
  ["CARD", /\b(?:\d[ -]?){12,18}\d\b/g, v => luhn(v.replace(/\D/g, ""))],
  ["IP", /\b\d{1,3}(?:\.\d{1,3}){3}\b/g, v => v.split(".").every(p => Number(p) <= 255)],
  ["PHONE", /\+\d{1,3}(?:[ -]?\d{2,4}){2,4}/g, v => true],
];

function redact(text) {
  const vault = new Map(), counts = {}, byValue = new Map();
  const place = (type, value) => {
    if (byValue.has(value)) return byValue.get(value);   // same value -> same placeholder
    counts[type] = (counts[type] || 0) + 1;
    const ph = `<${type}_${counts[type]}>`;
    vault.set(ph, { type, value }); byValue.set(value, ph);
    return ph;
  };
  for (const [type, re, valid] of DETECTORS) text = text.replace(re, m => (valid(m) ? place(type, m) : m));
  for (const name of KNOWN_NAMES) text = text.split(name).join(place("PERSON", name));
  return { text, vault };
}

function rehydrate(reply, vault) {
  const unknown = [];
  const out = reply.replace(/<([A-Z]+)_(\d+)>/g, ph => {
    const v = vault.get(ph);
    if (!v) { unknown.push(ph); return "[removed]"; }
    if (v.type === "CARD") return "card ending " + v.value.replace(/\D/g, "").slice(-4);  // never echo a full card
    return v.value;
  });
  return { out, unknown };
}

const logToken = v => "tok_" + crypto.createHmac("sha256", LOG_KEY).update(v.toLowerCase()).digest("hex").slice(0, 10);

function fakeModel(prompt) {                 // stands in for the provider call
  return "Hello <PERSON_1>, sorry about the double charge on <CARD_1>. We will refund it and confirm " +
    "by email to <EMAIL_1>. We will not call <PHONE_2>.";
}

const { text, vault } = redact(TICKET);
console.log("sent to provider:\n  " + text);
console.log("vault (stays local): " + [...vault.keys()].join(" "));
console.log("order number kept: " + text.includes("1234 5678 9012 3456") + " (16 digits but fails the Luhn check)");
const missed = TRUTH.filter(t => text.includes(t));
console.log("PII that still left the boundary: " + (missed.length ? missed.join(", ") : "none"));

const reply = fakeModel(text);
const { out, unknown } = rehydrate(reply, vault);
console.log("\nmodel reply (placeholders only):\n  " + reply);
console.log("rehydrated for the customer:\n  " + out);
console.log("unknown placeholders from the model: " + (unknown.join(", ") || "none"));

console.log("\nlog tokens (keyed HMAC, linkable but not reversible without the key):");
for (const v of ["maria.lopez@example.com", "Maria.Lopez@example.com", "tom@example.com"])
  console.log(`  ${v.padEnd(24)} -> ${logToken(v)}`);

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 50 of 83 in AI engineering

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

Up nextMedium

Chunking in RAG: how do you pick size, overlap, and strategy?

Continue