Blog··8 min read

Reversible PII redaction: how redact-then-restore works

Godfrey Lebo
Godfrey Lebo
Founder, Sether · Last updated 2026-07-09
Direct answer

Reversible PII redaction replaces each personal value with a stable token like <EMAIL_uuid> before a prompt leaves your infrastructure, stores the token-to-value mapping in a local vault, and swaps the real values back into the model reply. The LLM provider never sees real data; the user never sees tokens.

TL;DR
  • One-way masking makes chat replies unusable: the model answers about [REDACTED] and the user sees it.
  • Stable typed tokens keep the model reasoning normally and stay consistent across a multi-turn conversation.
  • The vault (in-memory LRU by default, pluggable for Redis) is the only place tokens map back to values, and it never leaves your infra.
  • Redact and restore must run on the same instance, because the vault lives on the instance.
  • Reversibility is wrong for logs, analytics, and training data. Use one-way redaction there.

Why does one-way masking break chat apps?

Classic redaction is one-way: find the email, replace it with [REDACTED], done. That is correct for logs and documents, where nobody needs the value back.

Chat breaks this model. A user writes "email my invoice to sarah@acme.com", you mask it, and the model dutifully replies "I have sent the invoice to [REDACTED]". The user stares at a broken product, and your team quietly turns redaction off.

The fix is a coat check. You hand over the real value at the door, get a numbered ticket, and the ticket is worthless to anyone else; on the way out, the ticket becomes the real thing again. That is the whole metaphor, and the rest of this post is the machinery.

What makes a good redaction token?

Sether tokens look like <EMAIL_uuid>: a type prefix plus a unique identifier. Both parts do work.

  • The type prefix tells the model what kind of value sits there. It can still say "I will send the invoice to <EMAIL_uuid>" and reason about it as an email address.
  • The unique identifier makes the token meaningless outside your system. There is nothing to reverse, no hash to crack; the mapping exists only in your vault.
  • Stability means the same value gets the same token for as long as the vault holds it. If sarah@acme.com appears three times, it becomes the same token three times, so the model knows they are one address.

Where do the real values live?

In the vault: a key-value store mapping tokens to original values. Sether ships MemoryVault, an in-process LRU that holds 10,000 entries with a 1-hour TTL.

The vault is a pluggable interface, not a fixed implementation. When one process is not enough, back it with Redis or any shared store, and redact and restore can then happen on different machines. What never changes is where the vault runs: inside your infrastructure, never at the provider.

Why must the same instance redact and restore?

Because the vault lives on the instance. When redact() mints a token, it writes the mapping into that instance's vault; when restore() meets the token in the reply, it reads the same vault back.

Create a second instance for the restore side and you get a fresh, empty vault. The tokens come through untouched and your user sees <EMAIL_uuid> in the reply. This is the most common integration mistake, and it is also the easiest to catch: one round-trip test finds it immediately.

How does this survive a multi-turn conversation?

Stable tokens carry the conversation. Turn one redacts sarah@acme.com to a token; turn three, when the user says "actually send it to that same address", the history you resend contains the same token, so the model refers back to it consistently.

The model never learns the real address across any number of turns, yet the thread stays coherent. The 1-hour default TTL fits a live session; for conversations that must resume days later, a persistent vault with a longer TTL is the knob to turn.

What are the security properties?

  • The provider never holds real values. Prompts, logs, and any retention on their side contain tokens only.
  • Tokens are meaningless outside your boundary. A leaked prompt, a provider breach, or a screenshot exposes identifiers with no mapping attached.
  • The vault stays in your infra. The token-to-value mapping never crosses the wire to anyone.
  • TTL bounds exposure. Entries expire (1 hour by default), so even inside your systems, the mapping is not held longer than the session needs.

To be precise about the claim: this is a technical measure that helps with data minimisation. It shrinks what leaves your boundary; it does not remove the need for processor agreements or a lawful basis, which we walk through in the GDPR post.

When is reversibility the wrong choice?

Reversibility exists to serve a live user who needs the real value back in the reply. If no user is waiting, keeping a path back to the data is a liability, not a feature.

DestinationRedaction modeWhy
LLM prompt in a chat appReversibleA user is waiting for a reply that must read naturally
Application logsOne-wayNobody should ever recover PII from a log line
Analytics and metrics pipelinesOne-wayAggregates need counts, not identities
Training or fine-tuning dataOne-wayA restore path into a dataset defeats the purpose of redacting it
Data shared with third partiesOne-wayYou cannot control their handling of a reversible token stream

A good rule: reversible on the request path, one-way everywhere data comes to rest.

The round trip in code

In TypeScript, redact and restore are stream transforms on one instance. The full tutorial, including the drop-in OpenAI wrapper and SSE streaming, is here.

TypeScript: one instance, both directions
import { Sether } from '@raeven-co/sether';
import { Readable } from 'node:stream';

const sether = new Sether(); // holds the vault

// Outgoing: values become tokens
const safeForLLM = Readable.from([userMessage]).pipe(sether.redact());

// Incoming: tokens become values again, same instance
const safeForUser = llmResponse.pipe(sether.restore());

In Python, the same design with sync calls (async and streaming also exist; the Python walkthrough covers them).

Python: same instance, both directions
from sether import Sether

sether = Sether()  # holds the vault

safe = sether.redact_sync(user_message)   # values -> tokens
reply = call_llm(safe)
restored = sether.restore_sync(reply)     # tokens -> values

One subtlety worth knowing before you ship: restoring a streamed reply means tokens can split across chunks, and a naive restore misses them. Sether holds back a small window to stay boundary-safe; the details are in the chunk-boundary bug post.

Frequently asked questions

Is a redaction token the same as encryption?
No, and that is a feature. An encrypted value can be decrypted by anyone with the key. A token is a random identifier with no mathematical relationship to the value; the only way back is the vault, which never leaves your infrastructure.
What happens if a vault entry expires before the reply arrives?
The token cannot be resolved and stays in the output as-is. In practice the default 1-hour TTL far exceeds any single request-reply cycle; expiry matters for conversations resumed much later, where a persistent vault with a longer TTL is the answer.
Can the LLM provider reverse the tokens?
No. The token is a type prefix plus a random identifier. Without the vault there is no mapping to recover, so a provider-side leak of your prompts exposes tokens, not data.
Should I use reversible redaction for my logs too?
No. Logs are data at rest with no user waiting on a reply, so a restore path is pure liability there. Use one-way redaction for logs, analytics, and training data; keep reversibility for the live request path.
Try it

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