How to redact PII in JSON and structured data before sending it to an LLM
To redact PII inside a JSON payload before an LLM sees it, run the values through a detector that anchors on the JSON key, not just prose labels. Sether's identity pack fires on keys like "customer_name" or "date_of_birth", swaps the value for a typed token, and keeps the payload valid JSON. The model reasons over tokens; you restore the real values in the reply.
- The PII your backend sends to an LLM is usually in JSON values, and the key right next to each value names exactly what it is.
- Redactors tuned for chat prose ("Name: Sarah") miss
"customer_name": "Sarah", because there is no sentence, only structure. - Sether 0.6.0 (npm) and 0.2.0 (Python) anchor the identity pack on JSON keys, so structured payloads get redacted too.
- Value validators (uppercase-first names, calendar-plausible dates, digit-bearing passports) stop a loose key match from over-firing.
- Address capture stops at the closing quote, so the redacted output is still valid JSON you can pass straight to the model.
Your PII is in the values, and the keys name it
Most writing about redaction for LLMs assumes a chat box: a human types a sentence, and somewhere in that sentence is an email or a card number. But a large share of the data that backends send to OpenAI or Claude never looked like a sentence. It is a JSON object: a form submission, an API request body, a row pulled from your database, a webhook payload you are asking the model to classify, extract, or turn into a document.
{
"customer_name": "Amara Okafor",
"date_of_birth": "1990-05-12",
"passport_number": "A1234567",
"billing_address": "12 Marina Road, Lagos",
"question": "Can you update the delivery address on my order?"
}Four of those five fields are personal data, and the thing that makes them easy to leak is the same thing that makes them easy to catch: the key sitting immediately to the left of each value spells out precisely what the value is. customer_name is a name. date_of_birth is a DOB. You do not need to guess.
Why prose-tuned redaction walks right past this
A redactor built for chat looks for a label followed by a value in running text: "Name: Sarah Chen", "DOB 1990-05-12", "my passport is A1234567". That is a good, low-false-positive strategy for prose, and it is exactly what Sether's identity pack did originally. The trouble is that "customer_name": "Amara Okafor" is not that shape. There is no salutation, no sentence, no natural-language label. There is a quoted key, a colon, and a quoted value.
So a prose detector sees the address in "Address: 12 Marina Road" but misses "billing_address": "12 Marina Road, Lagos", and a whole category of real traffic slips through unredacted. This is the gap Sether closes in 0.6.0. The full trade-off between label-anchored detection and free-text NER is covered in Regex vs NER for PII detection; this post is about the structured case specifically.
Enable the identity pack (TypeScript and Python)
JSON-key detection lives in the opt-in identity pack, not the default basic pack, because names and addresses are higher-variance than checksummed data like cards or IBANs. You turn it on by adding identityDetectors to the detector list when you construct the instance.
import { Sether, basicDetectors, identityDetectors } from '@raeven-co/sether';
const sether = new Sether({
detectors: [...basicDetectors, ...identityDetectors],
});
const payload = JSON.stringify({
customer_name: 'Amara Okafor',
date_of_birth: '1990-05-12',
passport_number: 'A1234567',
billing_address: '12 Marina Road, Lagos',
});
const safe = sether.redactSync(payload);
// -> {"customer_name":"<NAME_...>","date_of_birth":"<DOB_...>", ...}
// send `safe` to the model, then restore real values in the reply
const reply = sether.restoreSync(modelReply);from sether import Sether, basic_detectors, identity_detectors
sether = Sether(detectors=[*basic_detectors, *identity_detectors])
payload = '{"customer_name": "Amara Okafor", "date_of_birth": "1990-05-12"}'
safe = sether.redact_sync(payload)
# -> {"customer_name": "<NAME_...>", "date_of_birth": "<DOB_...>"}
reply = sether.restore_sync(model_reply)The API is identical in shape across both languages: one instance, redact_sync on the way out, restore_sync on the way back, and a shared vault on the instance that maps each token to its original value. The round-trip mechanics are the same as the prose case, walked through in How to redact PII before OpenAI in TypeScript and Redact PII in LLM prompts with Python.
What the JSON-key detection catches
The identity pack fires when a JSON key contains a class word, across the naming styles real codebases actually use: snake_case, kebab-case, camelCase, and spaced keys. It matches the key, then applies the same value validation it uses for prose.
| Class | Example keys it fires on | Token |
|---|---|---|
| Name | "customer_name", "full_name", "firstName", "patient name" | <NAME_uuid> |
| Date of birth | "date_of_birth", "dob", "birthDate" | <DOB_uuid> |
| Passport | "passport_number", "passport", "passportNo" | <PASSPORT_uuid> |
| Address | "billing_address", "shipping_addr", "home address" | <ADDRESS_uuid> |
Each token keeps its type prefix, so the model still knows a name belongs where the name was and reasons normally. What it cannot do is see the actual person, because the uuid half of the token is meaningless without your vault.
The redacted output is still valid JSON
This is the detail that makes structured redaction usable rather than a nice demo. If a detector captures a value greedily, it will swallow the closing quote and the comma, and the payload you send to the model is no longer parseable JSON. Sether's address detector, the one most prone to running long, stops its capture at the closing double quote.
// in
{ "billing_address": "12 Marina Road, Lagos", "priority": "high" }
// out
{ "billing_address": "<ADDRESS_9f2c...>", "priority": "high" }The token lands cleanly inside the quotes, the comma and the next key are untouched, and the object still parses. That means you can redact a payload, hand it to a model that expects JSON, and restore the reply without ever re-serialising by hand.
Why a loose key match does not over-fire
The obvious risk with matching on keys is that "name" appears in keys that are not personal at all: "filename", "username", "hostname", "nickname". Matching the key alone would redact half your config. Sether guards against this by validating the value, not trusting the key.
- Names must look like names: an uppercase-first token, checked against a denylist of placeholders and common words.
"filename": "report.pdf"and"username": "amara_dev"do not fire, because "report.pdf" and "amara_dev" are not name-shaped. - Dates of birth must be calendar-plausible.
"birth_place": "Lagos"never fires (Lagos is not a date), and"date_of_birth": "2099-01-01"is rejected as an implausible future year. - Passports must carry at least one digit, so
"passport": "VALIDONLY"is ignored while"passport_number": "A1234567"is caught. - The match requires the
"key": "value"shape, so ordinary prose that merely mentions a class word ("the name is unknown right now") is left completely alone.
The design rule is that a permissive key match is only ever a candidate; the value decides. That keeps the false-positive rate on structured payloads close to what checksum-validated detectors like the card and IBAN checks already deliver.
Streaming structured data
If you stream large payloads or the model's JSON response back to a browser, the same chunk-boundary rules apply as with prose: a key or value split across two chunks must still be detected as one unit. Sether's hold-back window keeps the last safeDistanceBytes (256 by default) unemitted until no match can span the boundary, and the streaming interfaces (createRedactStream, redact_stream) carry this for you.
The failure mode this prevents, and how it was caught in testing, is written up in The streaming redaction chunk-boundary bug. For structured data the lesson is the same: never run redaction per-chunk without a hold-back, or a value straddling a boundary escapes.
Limitations, honestly
- This catches PII whose key names the class. A value sitting under an opaque key like
"field_7": "Amara Okafor"has no signal in the key, so the JSON-key path will not fire on it; that is the free-text NER problem, not the structured one. - The basic pack (email, phone, card, SSN, IPv4/IPv6, IBAN) fires on values everywhere regardless of key, because those are self-describing. The key-anchoring only matters for name, DOB, passport, and address, which are not.
- Redaction is a technical control that supports data minimisation. It does not by itself make a data flow lawful. The legal reasoning for sending customer data to a provider is in Is it GDPR-compliant to send customer data to OpenAI?.
- The default vault is in-memory. If redact and restore run in different processes, back it with a shared vault or the tokens cannot be resolved.
You can paste a JSON payload straight into the live sandbox and watch the fields tokenize, the full API is at /docs, and the source for both packages is at github.com/raeven-co/sether.
Frequently asked questions
- Does redacting a JSON value break the JSON structure?
- No. Tokens are inserted inside the value's quotes and the address detector stops its capture at the closing quote, so commas, keys, and brackets are untouched. The redacted payload still parses as valid JSON and can be sent straight to a model that expects JSON.
- Will it redact keys like "filename" or "username" by mistake?
- No. Matching the key only makes the value a candidate; the value must then pass validation. "report.pdf" and "amara_dev" are not name-shaped, so those keys never fire. The value decides, not the key.
- What if the personal data is under a meaningless key like "field_7"?
- The JSON-key path relies on the key naming the class, so an opaque key gives it nothing to anchor on. Self-describing values (email, phone, card) are still caught by the basic pack regardless of key; names and addresses under opaque keys need the free-text NER add-on.
- Which versions have JSON-key detection?
- The @raeven-co/sether npm package from 0.6.0 and the sether Python package from 0.2.0. It is part of the opt-in identity pack, so enable identityDetectors (identity_detectors in Python) when you construct the instance.
Sether is free and MIT-licensed: npm i @raeven-co/sether · pip install sether. Or paste some text into the live sandbox and watch the redaction happen.
Keep reading
- PII redaction for LLM applications: the complete guide (2026)How to keep names, emails, cards, and secrets out of OpenAI, Anthropic, and other LLM providers: detection methods, reversible tokenization, streaming pitfalls, and tool comparison.
- Regex vs NER for PII detection: when patterns aren't enoughWhere validated patterns win, where label anchoring helps, and where only NER catches PII. Trade-offs, local ONNX inference, and a practical recommendation ladder.
- How to redact PII before sending prompts to OpenAI (TypeScript)A hands-on TypeScript tutorial: detect and redact emails, phones, cards, and API keys before they reach OpenAI, then restore them in the reply. Streaming included.
- How to redact PII from LLM prompts in PythonA Python tutorial: strip emails, phones, cards, and secrets from prompts before OpenAI or Anthropic sees them, then restore them in the reply. FastAPI and Flask included.