◉CLAUDE LABJP
●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…●2.1.301 — Adds the vim % motion, LaTeX math rendering in the VS Code panel, and a retry-watchdog limit for usage-limit waits (10/15)●2.1.299 — Conversations over 200K tokens left idle about 54 minutes get compacted; MCP allow rules now match only the server they name●SONNET 4.5 — Retires on the Claude API on 11/30, 45 days left. Migrate to Sonnet 5.5●WINDOWS — Reports say hooks break silently only on Japanese-locale Windows; check the character encoding first●FAST MODE — Opus 5.5 fast mode is out of research preview and no longer needs the beta header (10/15)●NEW — I Put a Ten-Line Promise File at the To…
Articles/API & SDK
⬡ API & SDK/2026-06-16Advanced

PII Masking for Claude API Lives or Dies on the Ledger — Restore, Encrypt, Measure

The hard part of masking PII before Claude API isn't detection — it's operating the token ledger you restore from. Encrypted storage, multi-instance sharing, and a daily leak-rate loop, with working code.

claude-api83piisecurity19production111privacy3observability21

✦ Premium Article

Few people argue with the idea of masking PII before it reaches the Claude API. The trouble starts right after. Once you've built a detector from regexes and an NER model, you're left with operational questions: how do you put the masked values back, where does that mapping live, and how do you prove nothing leaked? This is where the design suddenly gets hard.

The detection logic comes together in a few days. What hurts in production is almost never detection — it's the handling of the restore ledger and the absence of a way to keep measuring leak rate. Across the business assistants I've run on the Claude API as an indie developer, nearly every review finding and incident traced back to those two things. This article walks through building a detector, but puts its weight on ledger operations and continuous measurement, with the implementation code to match.

Think in round trips, not one-way trips

If you treat masking as "strip it before sending," you will break every summarization or assistant use case. When Claude replies with Contacted <PERSON_001>, that string is meaningless to the user until you turn <PERSON_001> back into Taro Tanaka. Masking is not a one-way send-time step — it's a round trip where masking (outbound) and restoration (inbound) are a matched pair.

Seen as a round trip, the protagonist is not the detector but the ledger that maps tokens to originals. The ledger has to satisfy three things at once.

First, it must be consistent across turns and requests. The same Taro Tanaka must always get the same <PERSON_001>, or Claude can't tell it's the same person and output quality degrades. Second, it must be encrypted. The ledger is literally a map of which token is whom, so a leaked ledger is worse than leaked raw PII. Third, it must be shareable across instances, because production never runs in a single process.

If you only need irreversible masking (collapse everything to <PERSON>), you don't need a ledger at all. The ledger exists only for restoration. That's why "do we default to reversible?" is the first fork that decides your operational cost. When I'm unsure whether restoration will be needed, I choose reversible: dropping to irreversible later is one line, but going from irreversible back to reversible is impossible because the original is already gone.

Two detection layers are enough — Luhn-filtered regex and a Haiku NER

Before the ledger, lock down detection in its minimal form. In my deployments, two layers were plenty: regex (Layer 1: identifiers) and a lightweight-model NER (Layer 2: quasi-identifiers). Layer 3, the kind that needs contextual inference, I don't try to machine-process — I push it to input guidance in the UI.

The key in the regex layer is to filter card-like digit runs through a Luhn check. Catching "13+ digits" alone sweeps up ISBNs, JANs, and tracking numbers, and summarization accuracy visibly drops.

// pii-detect.ts — Layer 1 (identifiers). Tokens use a uniform <CATEGORY_NNN> shape
// Rationale: false positives hurt model quality, misses hurt privacy. Luhn curbs card false positives
export type Span = { start: number; end: number; category: string; text: string };
 
const REGEXES: Record<string, RegExp> = {
  EMAIL: /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g,
  PHONE_JP: /(?:\+?81[-\s]?|0)\d{1,4}[-\s]?\d{1,4}[-\s]?\d{3,4}/g,
  CREDIT_CARD: /\b(?:\d[ -]*?){13,19}\b/g,
};
 
export function detectLayer1(input: string): Span[] {
  const spans: Span[] = [];
  for (const [category, regex] of Object.entries(REGEXES)) {
    for (const m of input.matchAll(regex)) {
      const text = m[0];
      if (category === "CREDIT_CARD" && !isLuhnValid(text.replace(/[ -]/g, ""))) continue;
      spans.push({ start: m.index!, end: m.index! + text.length, category, text });
    }
  }
  return spans;
}
 
function isLuhnValid(num: string): boolean {
  if (!/^\d{13,19}$/.test(num)) return false;
  let sum = 0, alt = false;
  for (let i = num.length - 1; i >= 0; i--) {
    let n = parseInt(num[i], 10);
    if (alt) { n *= 2; if (n > 9) n -= 9; }
    sum += n; alt = !alt;
  }
  return sum % 10 === 0;
}

Names and addresses won't fall to regex. Here I use Claude Haiku as an NER. Since the NER input also contains PII, keeping it inside a Claude call I control is easier to justify in an audit than shipping it to a generic cloud NER. The crucial part is to not over-trust the output and to pin a fixed model version (see pitfall 3 below).

// pii-ner.ts — Layer 2 (quasi-identifiers: names, addresses). Pin the model to a dated ID
import Anthropic from "@anthropic-ai/sdk";
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! });
 
const NER_MODEL = "claude-haiku-4-5-20251001"; // dated ID to freeze behavior
const NER_SYSTEM = `You are a Japanese named-entity extractor. Extract only PERSON and ADDRESS_JP spans
from the input and return strictly this JSON. No guessing, no invention. Omit anything you cannot classify.
{"entities":[{"text":"string","category":"PERSON"|"ADDRESS_JP"}]}`;
 
export async function detectLayer2(input: string): Promise<{ text: string; category: string }[]> {
  const res = await client.messages.create({
    model: NER_MODEL, max_tokens: 1024, system: NER_SYSTEM,
    messages: [{ role: "user", content: input }],
  });
  const text = res.content.filter((b) => b.type === "text").map((b: any) => b.text).join("");
  const match = text.match(/\{[\s\S]*\}/);
  if (!match) return []; // nothing extracted -> rely on the rescan to catch misses
  try {
    const parsed = JSON.parse(match[0]) as { entities?: { text: string; category: string }[] };
    return (parsed.entities ?? []).filter((e) => e.text && ["PERSON", "ADDRESS_JP"].includes(e.category));
  } catch {
    return []; // on parse failure, skip and let measurement catch the leak
  }
}
✦

Thank you for reading this far.

Continue Reading

What follows includes implementation code, benchmarks, and practical content we hope you'll find useful. This site runs without ads — server and development costs are supported entirely by members like you. If it's been helpful, we'd be truly grateful for your support.

WHAT YOU'LL LEARN
✦Encrypting the restore ledger with AES-GCM and sharing it across instances
✦Quantifying leak rate every day with a golden dataset and shadow rescan
✦Killing the three operational bugs: stream splitting, token translation, NER drift
Secure payment via Stripe · Cancel anytime
✦

Unlock This Article

Get full access to the rest of this article. Buy once, read anytime. This site is ad-free — your support goes directly toward keeping it running.

or
Unlock all articles with Membership →
Share

Thank You for Reading

Claude Lab is ad-free, supported entirely by members like you. We publish practical guides daily with implementation code, benchmarks, and production-ready patterns. If you've found it useful, we'd love to have you on board.

  • ✦Copy-paste ready implementation code
  • ✦New advanced guides published daily
  • ✦$5/mo or $10 for lifetime access
View Membership →

Related Articles

⬡ API & SDK2026-05-01
Claude API Telemetry on ClickHouse: A Production Guide to Cost, Latency, and Error Analytics
Stream per-request Claude API telemetry into ClickHouse, build sub-second dashboards with materialized views, and detect cost spikes, retry loops, and silent failures with practical SQL recipes.
⬡ API & SDK2026-04-30
Building a Production Claude API Pipeline on Cloudflare Queues: Fault Tolerance, Backpressure, and Cost Control
A practical, code-first walkthrough for routing Claude API calls through Cloudflare Queues — covering producer/consumer code, retry-vs-DLQ branching, priority lanes, and token budgeting for production workloads.
⬡ API & SDK2026-04-29
Production Semantic Cache for Claude API — Similarity Thresholds, Pollution Defense, and What to Track
A production playbook for adding a semantic cache in front of Claude API — threshold tuning, multi-tenant isolation, pollution prevention, fallbacks, how it stacks with Anthropic's prompt caching, and the metrics that actually prove it works.