json · structured data · pii redaction · typescript · python · llm

How to redact PII in JSON and structured data before sending it to an LLM

Redact supported personal values in JSON, verify syntax and coverage, then restore replies safely with JSON-aware APIs in TypeScript and Python.

Godfrey Lebo Founder, Sether · updated · 10 min read
In this article
Conceptual Sether illustration: Keep the structure. Replace the detail. Sether™ / Field notes json Keep the structure. Replace the detail. Redact supported values in structured data.
Conceptual illustration with fictional data.
Direct answer

For JSON sent to an LLM, use structure-aware detection and verify the resulting payload still parses. Sether’s opt-in identity pack uses supported key cues for names, dates of birth, passports and addresses; other detectors recognize structured values. Restore complete JSON replies with JSON-aware APIs, then validate the resulting schema and intended fields.

TL;DR
  • 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 narrow candidates, but do not prove every valid-looking value is personal or catch every real format.
  • Test JSON syntax and field structure separately from detection coverage. Restoring quoted originals requires JSON-aware restoration.

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.

Animation: values inside a JSON payload are replaced with typed tokens while the keys and structure stay intact.
Key-anchored redaction keeps the payload valid JSON. See the identity pack →
A payload your service might send to an LLM to draft a reply
{
  "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.

TypeScript — @raeven-co/sether 0.6.0+
import {
  Sether,
  basicDetectors,
  identityDetectors,
  redactSync,
  restoreSync,
} 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 = redactSync(payload, { detectors: sether.detectors, vault: sether.vault });
// -> {"customer_name":"<NAME_...>","date_of_birth":"<DOB_...>", ...}

// send `safe` to the model. Its reply is JSON too: parse it first,
// then restore the string fields you are going to use.
const draft = JSON.parse(modelReply) as { subject: string; body: string };
const subject = restoreSync(draft.subject, { vault: sether.vault });
const body = restoreSync(draft.body, { vault: sether.vault });
Python — sether 0.2.0+
import json

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_...>"}

# the reply is JSON too: parse it, then restore the fields you use
draft = json.loads(model_reply)
subject = sether.restore_sync(draft["subject"])
body = sether.restore_sync(draft["body"])

The shape is the same in both languages: one instance holds the detectors and the vault that maps each token to its original value. In Python you call redact_sync and restore_sync on the instance. In TypeScript the one-shot helpers are the exported redactSync and restoreSync functions, and you pass them the instance's detectors and vault. On the way back, parse the model's JSON first and restore the string fields you use. 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.

To restore a whole JSON reply in one call, restoreJson (npm 0.8.0) and restore_json (PyPI 0.4.0) parse it, restore each string value and re-escape it. Never run restoreSync or restore_sync over serialized JSON as text: an original containing a quote can add keys. Why, and what changed in 0.8.0.

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>

The type prefix gives the model a field hint. It does not establish anonymity: other fields or the surrounding record may still identify the person. Check which details the task needs, and validate whether the model preserves the replacement correctly.

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.

Before and after — the structure survives
// in
{ "billing_address": "12 Marina Road, Lagos", "priority": "high" }

// out
{ "billing_address": "<ADDRESS_9f2c...>", "priority": "high" }

The illustrated token sits inside the value’s quotes, leaving that sample’s other fields intact. Make parsing an assertion in your own fixtures rather than assuming arbitrary serialized input is covered. On the return path, use JSON-aware restoration and validate keys, types and application permissions before using the result.

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.

A permissive key match creates a candidate, and the value validator narrows it. That is a useful heuristic, not a measured accuracy guarantee. Include both names your users actually have and innocent values such as a document title so the trade-off is visible.

Streaming structured data

Streaming serialized records adds both text boundaries and JSON framing. Sether uses a configurable hold-back distance (safeDistanceBytes, default setting 256) and bounded buffering safeguards, which need long-value and custom-detector tests. If a model streams one JSON document as content fragments, collect it before JSON-aware restore; a stream of independently parseable event envelopes is not the same thing.

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.

Acceptance check Example assertion
Syntax The redacted payload and JSON-aware restored reply both parse.
Structure Keys, nesting and field types match the application schema.
Coverage Expected sample names and identifiers were replaced; harmless fields were retained.
Adversarial originals Quotes, backslashes and nested serialized strings cannot become new keys.
Execution A restored destination is still checked against authorization and business rules.

The key-detection versions described above are historical introduction points. For structured reply restoration, use the APIs added in npm 0.8.0 or Python 0.4.0. These are full MIT library capabilities, independent of optional Pro workflows in the official tools.

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?

The illustrated fixtures preserve their JSON syntax, but validate your actual payloads. Parse the edited document and assert that expected keys and types remain. For restoration, use restoreJson/restore_json or restore parsed string values; raw text substitution into serialized JSON can change its structure.

Will it redact keys like "filename" or "username" by mistake?

Those illustrated values are rejected by the name validators, but this is not a guarantee for every string under those keys. Test harmless lookalikes and supported personal-name formats in your own records; keys and value validators both influence the result.

What if the personal data is under a meaningless key like "field_7"?

An opaque key provides no identity-class cue. Other enabled detectors may still recognize values such as an email or card candidate. Names and addresses may need a known-term detector, schema-specific handling or evaluated NER; none guarantees complete coverage.

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.

Godfrey Lebo

Written by Godfrey Lebo

Founder of Sether. Ships production software for a living and reads every comment below. More about the team.

Share: X LinkedIn Markdown

Comments

Emails and phone numbers in comments are hidden automatically. We take our own medicine.

No comments yet. Ask a question or share how you handle this. First comment gets our undivided attention.

Join the conversation

Questions, corrections and war stories are all welcome. Be kind, stay on topic.

By posting you agree to our terms. We store a one-way hash of your IP to fight spam, never the IP itself.