Developer guide · Reversible masking
How to restore masked personal data in LLM responses
Short answer
Replace each sensitive value with a token before the request, keep the token-to-value mapping somewhere the model can't reach, and swap tokens back in the response. The hard parts are streaming, where a token can split across chunks, JSON and tool calls, RAG indexes that need stable tokens, and deciding what happens when masking fails.
Redaction alone gives you answers like "Dear [PERSON], your refund of CHF 2,400 to [IBAN] has been sent." That's safe and useless: someone still has to fill in the blanks by hand. Reversible masking lets the model write "Dear mf_mask_170b8521c1b944b3beb9ca02b328efbe" and lets your code turn that back into "Dear Ada" before anyone reads it. The provider never sees the name. The user never sees the token.
Getting the happy path working takes an afternoon. This guide is about the rest: streams, JSON, tool calls, RAG, other tenants' tokens, expiry and failure.
What is a masking token?
Maskflare produces two kinds. Both are resolved by the API for the key that created them, and the original values behind them are stored encrypted for 30 days.
| Random token | Deterministic stand-in | |
|---|---|---|
| Looks like | mf_mask_170b8521c1b944b3beb9ca02b328efbe | EMAIL_kovapeluzi |
| Same value, same token? | No, a new token per mask call | Yes, within the same organization, environment and subject tenant |
| Used for | Chat, summaries, one-off requests | Embeddings, RAG indexes, anything you match on later |
| How you get it | mask(text); the gateway on chat requests | mask(text, deterministic=True); the gateway on embeddings requests |
| Linkability | A token says nothing about other requests | Pseudonymous: equal stand-ins mean equal values |
Where is the mapping kept? Not in your prompt and not with the provider. Maskflare stores the original value behind each token encrypted, for 30 days, so it can be restored; prompts and responses themselves are never logged or stored. Your code only ever holds tokens and, after restoring, the values it already had.
Inside one request, the same value gets the same random token, so the model can still tell that two mentions refer to one customer. Across requests, random tokens don't link. That's the safer default for chat.
How do you restore values in a normal response?
With the gateway, you don't write code: restoring is switched on per gateway key, and answers come back with real values. With the SDK, it's two calls around your model call:
import os
from maskflare import MaskFlare
mf = MaskFlare(api_key=os.environ["MASKFLARE_API_KEY"]) # a Redaction-purpose key
result = mf.mask("Write to ada@example.com about the CHF 2,400 refund")
# result.output: "Write to mf_mask_170b8521c1b944b3beb9ca02b328efbe about the CHF 2,400 refund"
# result.tokens: (MaskedToken(token="170b8521c1b944b3beb9ca02b328efbe", rule_key="email"),)
answer = call_your_model(result.output) # the provider only sees the token
readable = mf.unmask_text(answer) # real values back; unknown tokens left as they areunmask_text (unmaskText in TypeScript) finds every mf_mask_ token and deterministic stand-in in the text, resolves them in batches of up to 100 and leaves anything it can't resolve untouched. It makes no request at all when the text has no candidate token. unmask(tokens) returns a token-to-value map instead, which is what you want for the next pattern.
Why should you restore only the tokens you minted?
Tokens resolve for the key that created them. In a multi-user app that shares one key, unmask_text on a model answer will resolve any of that key's tokens, including one a user copied from somewhere else and asked the model to repeat. That's a way to read another user's value. The fix is to restore only the tokens your mask call produced for this request:
import re
TOKEN = re.compile(r"mf_mask_([0-9a-f]{32})")
def restore(text: str, originals: dict[str, str]) -> str:
return TOKEN.sub(lambda m: originals.get(m.group(1), m.group(0)), text)
result = mf.mask(user_text)
originals = mf.unmask([t.token for t in result.tokens]) # only what we minted
answer = call_your_model(result.output)
readable = restore(answer, originals) # foreign tokens stay as tokensMaskflare's LiteLLM guardrail does exactly this: it remembers the tokens minted for each call and restores only those. For deterministic stand-ins, scope by customer with subject_tenant, so a stand-in from one tenant never resolves in another tenant's scope.
How do you restore tokens in a streamed response?
Streaming is where naive restoring breaks. Server-sent events arrive in small pieces, and a 40-character token can be cut anywhere: ...wrote to mf_ma in one chunk and sk_170b85... in the next. Replace per chunk and you miss it. Buffer the whole stream and you lose streaming.
The fix is a small holdback. Emit text as it comes, except a tail that could still turn into a token. The longest possible tail is mf_mask_ plus 31 hex characters, 39 characters in all, and you only hold it while the text actually ends that way:
import re
from collections.abc import Iterable, Iterator
TOKEN = re.compile(r"mf_mask_([0-9a-f]{32})")
# A suffix that could still grow into a token: "m", "mf_", ... up to "mf_mask_" + 31 hex
PARTIAL = re.compile(r"m(?:f(?:_(?:m(?:a(?:s(?:k(?:_[0-9a-f]{0,31})?)?)?)?)?)?)?$")
def restore(text: str, originals: dict[str, str]) -> str:
return TOKEN.sub(lambda m: originals.get(m.group(1), m.group(0)), text)
def restore_stream(pieces: Iterable[str], originals: dict[str, str]) -> Iterator[str]:
held = ""
for piece in pieces:
text = held + piece
partial = PARTIAL.search(text)
cut = partial.start() if partial else len(text)
held = text[cut:]
if cut:
yield restore(text[:cut], originals)
if held:
yield restore(held, originals) # end of stream: release whatever is leftFeed it the text deltas from your provider's stream and the originals map from the previous section; you resolve the tokens once, before the stream starts, so there's no API call per chunk. Most chunks pass straight through. The reader sees at most a 39-character pause, and only when the text really ends in something token-shaped. The Maskflare gateway and the LiteLLM guardrail do the same internally, for streamed text and for tool-call argument deltas.
How do deterministic tokens work for RAG and embeddings?
Embeddings need the same input to produce the same vector. If "Ada Lovelace" becomes a different random token every time, the chunk you indexed and the question you ask stop matching. So mask both sides deterministically, in the same scope:
# Ingest: mask every chunk before embedding and storing it
masked_chunk = mf.mask(chunk_text, deterministic=True, subject_tenant="acme").output
index.add(embed(masked_chunk), text=masked_chunk) # the index only holds stand-ins
# Query: mask the question the same way, so stand-ins match
question = mf.mask(user_question, deterministic=True, subject_tenant="acme").output
context = index.search(embed(question))
answer = call_your_model(question, context)
# Display: resolve stand-ins in the same tenant scope
readable = mf.unmask_text(answer, subject_tenant="acme")Through the gateway, embeddings requests get deterministic stand-ins automatically, and that path fails closed: if deterministic masking can't run, the provider is never called. Treat a deterministic index as pseudonymous data, not anonymous. Anyone who can call unmask in that scope can resolve it, and equal stand-ins reveal equal values. More on the pattern in RAG and embeddings.
What about tool calls and JSON mode?
- Parse first, restore second. A restored value can contain a double quote or a backslash: a nickname in quotes, a Windows path. Run
restoreon raw JSON text and you can produce invalid JSON. Parse the model's output, then restore each string value. - Validate after restoring. A schema that requires
format: emailwill rejectmf_mask_.... Restore before you validate structured output, or relax that field's format check. - Restore tool arguments before running the tool. When the model calls
send_email(to="mf_mask_..."), the tool needs the real address. The gateway restores tool-call arguments when restoring is on; with the SDK, restore the parsed arguments yourself. - Mask tool results on the way back. The CRM record your tool returns goes into the next request. The gateway masks tool results as part of the body; with the SDK, mask them before appending them to
messages. - Tell the model to copy tokens exactly. Models usually do, but a token the model shortens or changes is no longer a token, and it stays in the output as text. A line in the system prompt helps.
What happens after 30 days?
Tokens resolve for 30 days from creation, then expire. After that, unmask simply omits them and unmask_text leaves them in the text. That's fine for chat, where you restore within seconds. It matters for anything you store masked: a transcript archive, a RAG index, a queue of batch results you'll read next quarter. Keep the real values in your own system of record if you'll need them later, and treat stand-ins in long-lived stores as join keys rather than the only copy.
Should masking fail open or fail closed?
Decide per path, write it down, and test it by actually cutting the connection. Here's what each Maskflare component does:
| Path | If masking can't run | Can you change it? |
|---|---|---|
SDK (mask) | Raises MaskFlareError or MaskFlareConnectionError; your code decides | Yes: catch it and refuse, queue or continue |
| Gateway, embeddings and Batch uploads | Fails closed: the provider is never called | No |
| Gateway, chat requests | Outages of the platform behind it return 503; for a masking error on a single request, your organization setting decides | Yes: per organization, block the request or forward it and flag it |
| LiteLLM guardrail | fail_policy: closed (default) refuses with 503 | Yes: open forwards unmasked and logs a warning |
| Coding-agent hooks | Closed by default: prompts and tool calls that couldn't be checked are blocked | Yes: fail mode open |
| Desktop agent | Fail-closed by default | Yes, configurable |
Restoring failures are gentler: the user sees tokens instead of values, which is ugly but leaks nothing. Show a retry, not the raw answer from another path.
Frequently asked questions
Is reversible masking the same as anonymization?
No. If the original can be recovered, the data is pseudonymized, and GDPR still treats it as personal data on your side. The provider receives tokens, not direct identifiers.
How long do Maskflare tokens stay resolvable?
30 days from creation. After that unmask omits them and unmask_text leaves them in place. The original values behind tokens are stored encrypted for that period.
Can tokens be restored in the browser?
Maskflare's browser protection restores values where the AI web app's answer repeats them. Don't call the SDK from browser code: a Redaction key can resolve tokens and must stay on the server.
Why use random tokens for chat if deterministic ones are more convenient?
Random tokens don't link across requests, so a provider can't tell that two conversations mention the same person. Use deterministic stand-ins only where matching across requests is the point, such as embeddings.
What if the model changes a token?
Then it no longer matches the token pattern and is left as text. Ask the model to copy tokens exactly, and check answers for leftover mf_mask_ fragments in tests.
About the author
Guides from the engineers who build Maskflare's gateway, detection engine, desktop agent and SDKs. Every code sample is run against the product before it's published, and every limit we mention is one we hit ourselves.