How to redact PII before sending prompts to OpenAI (TypeScript)
To redact PII before calling OpenAI in TypeScript, install @raeven-co/sether, create one Sether instance, pipe your prompt through sether.redact() before the API call, and pipe the model reply through sether.restore(). The provider only sees tokens like <EMAIL_uuid>; your user sees the real values, restored from a local vault.
- Whatever a user types goes into the messages array verbatim and leaves your server in plaintext.
- Sether swaps values for stable tokens on the way out and restores them in the reply. Same instance, shared vault.
- wrapOpenAI gives you a drop-in wrapper so existing call sites do not change.
- Streaming is chunk-boundary safe, with SSE helpers for Server-Sent Events.
- Test with one assertion: restore(redact(x)) must equal x.
What actually leaves your server when you call OpenAI?
Open the network tab on any AI chat feature and look at the request body. The messages array contains exactly what the user typed: their name, their email, the card number they pasted while asking about a refund.
I built Sether after watching my own demo app do this. During a hardening pass before release, I traced a test prompt in the network tab and saw a sample customer email sitting in plaintext inside the request. Nothing in the SDK stops that. It is the default.
The fix is a redaction layer in the request path: replace personal values with tokens before the request leaves, and put the real values back in the reply. This post is the hands-on TypeScript version; the concepts are covered in the complete guide to PII redaction for LLM apps.
Install
npm install @raeven-co/setherThe package is MIT-licensed, needs Node 18+, ships ESM and CJS builds at roughly 35 KB, and has exactly one runtime dependency (libphonenumber-js, for phone validation). There is also a browser entry at @raeven-co/sether/browser if you need client-side redaction.
The 3-step round trip
The core pattern is three steps: redact the prompt, call OpenAI, restore the reply. One Sether instance handles both directions, because the instance holds the vault that maps tokens back to real values.
import { Sether } from '@raeven-co/sether';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
const sether = new Sether();
// 1. Redact the user message before it leaves
const safePrompt = await text(
Readable.from([userMessage]).pipe(sether.redact())
);
// 2. Call OpenAI with the redacted prompt
const completion = await openai.chat.completions.create({
model: 'gpt-4o-mini',
messages: [{ role: 'user', content: safePrompt }],
});
// 3. Restore real values in the reply, same instance
const reply = await text(
Readable.from([completion.choices[0].message.content ?? ''])
.pipe(sether.restore())
);If you are not streaming and just want a string in and a string out, redactSync does the outgoing side without the stream plumbing. The important rule is unchanged: the same instance must do redact and restore, because the vault lives on the instance.
Why the same instance matters, and what the vault actually stores, is its own topic. The deep dive is Reversible PII redaction: how redact-then-restore works.
Can I do this without changing my call sites?
Yes. Sether ships a wrapOpenAI middleware that wraps your existing OpenAI client so redaction happens on the way in and restoration on the way out, without touching each call site.
One design note worth knowing: the wrappers are structurally typed. Sether never imports the OpenAI SDK as a dependency, so you do not inherit a version pin or an extra package. The same applies to wrapAnthropic, wrapFetch, and createExpressMiddleware.
What gets detected out of the box?
| Pack | Detectors | Enabled by default |
|---|---|---|
| Basic | Email, phone (libphonenumber-js), credit card (regex + Luhn), SSN, IPv4, IPv6, IBAN (mod-97) | Yes |
| Secrets | AWS access key, OpenAI key, Anthropic key, GitHub PAT, Slack token, Stripe key, JWT, high-entropy strings | Opt-in |
| Identity | Label-anchored name, DOB, passport, address, with multilingual labels across Latin, CJK, Cyrillic, and Arabic scripts | Opt-in |
The basic pack validates, not just matches. A card number must pass a Luhn check and an IBAN must pass mod-97 before Sether redacts it, which keeps false positives near zero on structured PII.
The secrets pack matters more than most teams expect. Developers paste config and stack traces into prompts, and the high-entropy detector (Shannon entropy of at least 3.5 bits per character) catches keys that no fixed pattern knows about. Enable both extra packs when you construct the instance.
How do I handle streaming responses?
Most chat UIs stream over Server-Sent Events, and streaming is where naive redaction breaks. An email split across two chunks as sarah@ac and me.com passes a per-chunk regex on both sides.
Sether handles this with a hold-back window: it keeps the last safeDistanceBytes (default 256) unemitted until no match can span the boundary. For SSE specifically, use createSSERedactStream and createSSERestoreStream, which understand the event framing so tokens are restored inside the data payloads, not across them.
We wrote up the failure mode in detail, including how we caught it in testing before release, in the chunk-boundary bug post.
What do the tokens look like?
Tokens are typed and unique: <EMAIL_uuid>, where the uuid part is a fresh identifier stored in the vault. The type prefix means the model still knows it is looking at an email, so it reasons normally; the uuid means the token is meaningless to anyone without your vault.
The default vault is MemoryVault, an in-process LRU holding 10,000 entries with a 1-hour TTL. The vault interface is pluggable, so you can back it with Redis when one process is not enough.
How do I test the integration?
The property you care about is exact round-trip: restoring a redacted string must give back the original, byte for byte. One assertion covers it.
import { Sether } from '@raeven-co/sether';
import { Readable } from 'node:stream';
import { text } from 'node:stream/consumers';
import assert from 'node:assert';
const roundTrip = async (input: string) => {
const sether = new Sether();
const redacted = await text(
Readable.from([input]).pipe(sether.redact())
);
assert.ok(!redacted.includes('sarah@acme.com')); // value is gone
const restored = await text(
Readable.from([redacted]).pipe(sether.restore())
);
assert.strictEqual(restored, input); // and comes back exactly
};
await roundTrip('Hi, I am Sarah, my email is sarah@acme.com');Also test a value split across chunk boundaries: feed Readable.from(["sarah@ac", "me.com"]) into redact() and assert the output contains no email. Sether itself carries 134 passing tests for these cases, but your integration deserves its own.
Limitations, honestly
- Free-text names are not caught by the core package. "Tell Chidi I will send the money Friday" has no label and no structure, so it needs the NER add-on. The trade-offs are in Regex vs NER for PII detection.
- The identity pack is label-anchored. It catches "name: Sarah Chen" in many scripts, but not names floating in prose.
- The default vault is in-memory. If your redact and restore steps run in different processes, plug in a shared vault or the tokens cannot be resolved.
- Redaction is a technical measure that helps with data minimisation. It does not, on its own, make you compliant with anything. See the GDPR post for the legal side.
You can try all of this without installing anything in the live sandbox, and the full API is documented at /docs. The source is at github.com/raeven-co/sether.
Frequently asked questions
- Does this work with the OpenAI streaming API?
- Yes. Pipe the response stream through sether.restore(), or use createSSERestoreStream if you are proxying Server-Sent Events to a browser. The hold-back window keeps token boundaries safe across chunks.
- Do I need to change my OpenAI SDK version?
- No. wrapOpenAI is structurally typed and Sether never imports the OpenAI SDK, so there is no version coupling. The library has exactly one runtime dependency, libphonenumber-js.
- Will OpenAI answer worse because it sees tokens instead of real values?
- Rarely. Tokens keep their type prefix (<EMAIL_uuid>), so the model knows an email belongs there and reasons normally. Quality only drops when the task needs the literal value, such as validating an email format.
- Is this enough for GDPR?
- Redaction is a technical measure that helps, particularly with Article 5 data minimisation and Article 32 technical measures. It does not replace processor agreements or a lawful basis. The legal reasoning is in our GDPR post.
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.
- 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.
- Reversible PII redaction: how redact-then-restore worksWhy one-way masking breaks chat UX, how stable tokens and a local vault make redaction reversible, and when reversibility is the wrong choice.
- The chunk-boundary bug: why streaming PII redaction is hardHow a property-based test caught emails slipping through Sether's streaming redactor when values split across chunks, and the hold-back window that fixed it.