Skip to content

Developer guide · Reversible masking

How to restore masked personal data in LLM responses

Maskflare EngineeringPublished 6 min read

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 tokenDeterministic stand-in
Looks likemf_mask_170b8521c1b944b3beb9ca02b328efbeEMAIL_kovapeluzi
Same value, same token?No, a new token per mask callYes, within the same organization, environment and subject tenant
Used forChat, summaries, one-off requestsEmbeddings, RAG indexes, anything you match on later
How you get itmask(text); the gateway on chat requestsmask(text, deterministic=True); the gateway on embeddings requests
LinkabilityA token says nothing about other requestsPseudonymous: 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:

Python: mask, call the model, restore
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 are

unmask_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:

Python: restore only this request's tokens
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 tokens

Maskflare'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:

Python: restore tokens across stream chunks
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 left

Feed 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:

Python: deterministic masking for a RAG pipeline
# 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 restore on 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: email will reject mf_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:

PathIf masking can't runCan you change it?
SDK (mask)Raises MaskFlareError or MaskFlareConnectionError; your code decidesYes: catch it and refuse, queue or continue
Gateway, embeddings and Batch uploadsFails closed: the provider is never calledNo
Gateway, chat requestsOutages of the platform behind it return 503; for a masking error on a single request, your organization setting decidesYes: per organization, block the request or forward it and flag it
LiteLLM guardrailfail_policy: closed (default) refuses with 503Yes: open forwards unmasked and logs a warning
Coding-agent hooksClosed by default: prompts and tool calls that couldn't be checked are blockedYes: fail mode open
Desktop agentFail-closed by defaultYes, 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

Maskflare Engineering

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.

Keep reading

See it on your own data

Try Maskflare free.
Bring your hardest prompts.

28-day free trial or 10,000 requests, whichever comes first. Point one app at the gateway or enroll a few laptops,
and watch what gets masked. Or talk to us first.