Generative AIEthics and safety in AIAI safety concepts

Practical data masking

8 minutes read

We’ve covered what data masking is and the techniques behind it. Because data flows through various stages, we need multiple tools for protection. In this topic, we cover how you can implement data masking across the different layers of your application. For complete control over your guardrails, we will focus only on code-first frameworks and open-source libraries rather than cloud services that abstract this away.

Data layer

Initially, you have data sitting in files, databases, or training sets. It is much safer to clean this data before your AI models or indexers ever see it (static data masking). Microsoft Presidio is a great tool at this stage. It is a detection and anonymization engine that works with both text and images. It has two modules:

  • AnalyzerEngine — finds candidate PII entities using regex, named-entity recognition, and context clues. It returns an entity type and a confidence score for each found entity.

  • AnonymizerEngine — uses an operator to replace the entity with a masked value depending on the technique (e.g., redact, replace, mask, encrypt).

To use Presidio, you need the following packages:

pip install presidio_analyzer
pip install presidio_anonymizer
python -m spacy download en_core_web_lg

Notice that we’re also downloading spaCy's large English model (en_core_web_lg) because Presidio requires an NLP backend. Once installed, it’s ready for use:

from presidio_analyzer import AnalyzerEngine
from presidio_anonymizer import AnonymizerEngine
from presidio_anonymizer.entities import OperatorConfig

record = "Customer name: John Smith, credit card: 4716 5582 9013 4471"

analyzer = AnalyzerEngine()
anonymizer = AnonymizerEngine()

# Detect PII
results = analyzer.analyze(
    text=record, entities=["PERSON", "CREDIT_CARD"], language="en"
)

# Shrink credit-card span so the last 4 digits stay visible
for r in results:
    if r.entity_type == "CREDIT_CARD":
        r.end -= 4

# define operators
operators = {
    "PERSON": OperatorConfig("replace", {"new_value": "John Doe"}),
    "CREDIT_CARD": OperatorConfig("replace", {"new_value": "**** **** **** "}),
}

masked = anonymizer.anonymize(
    text=record, analyzer_results=results, operators=operators
)

print(masked.text)

# output: Customer name: John Doe, credit card: **** **** **** 4471

The operator is configurable for each entity type:

operators = {
    "PERSON": OperatorConfig("replace", {"new_value": "John Doe"}),
    "CREDIT_CARD": OperatorConfig("mask", {"masking_char": "*", "chars_to_mask": 14, "from_end": False}),
    "EMAIL_ADDRESS": OperatorConfig("hash", {"hash_type": "sha256"}),
}

Each operator produces a different kind of substitute:

operator   "4716 5582 9013 4471"   ──▶   result
--------   ---------------------         ----------------------
replace    ...........................   "4716 5234 4321 9813"
mask       ...........................   "************4471"
hash       ...........................   "a3f5...e9c1"
encrypt    ...........................   "k8Fz1pQ2vR9t..."  (reversible with key)

You can also use the BatchAnalyzer() and BatchAnonymizer() engines to process a large corpus of data. This is useful for training datasets or a RAG knowledge base. However, remember that detection is statistical. Therefore, an unusually formatted identifier can still slip past the analyzer.

Orchestration layer

When your application is running, data is constantly moving. Your code might take in a user's prompt, pull a document from a database, and send everything to an LLM. When using frameworks like LangChain and LlamaIndex, you get tools to mask data in motion.

Here’s an example using LangChain’s PIIMiddleware:

# install packages: pip install langchain langchain-openai python-dotenv
from langchain.agents import create_agent
from langchain.agents.middleware import PIIMiddleware
from dotenv import load_dotenv

load_dotenv()

agent = create_agent(
    model="openai:gpt-4o-mini",
    middleware=[
        PIIMiddleware("email", strategy="redact"), 
        PIIMiddleware("credit_card", strategy="mask")
    ],
)

response = agent.invoke({
    "messages": [{
        "role": "user", "content": "You're given customer email: [email protected], credit card: 4716 5582 9013 4471. "
                                   "What personal data can you see in this input?"
    }]
})

print(f"{response['messages'][-1].content}")

Using Langfuse tracing, we can also see the masked input to the model:

Langfuse tracing showing the data before and after redaction.

You can use the PIIMiddleware to both inputs and model outputs. If you’re using LlamaIndex, especially for RAG workflows, you can use PII postprocessors to remove private information from retrieved chunks.

Guardrails

Frameworks like NeMo guardrails and LLM Guard can also be used to perform deterministic checks and reject/mask PII. They can be integrated with Presidio and local LLMs for these checks. Here’s an example configuration for NeMo guardrails:

rails:
  input:
    flows:
      - mask sensitive data on input
  config:
    sensitive_data_detection:
      input:
        entities:
          - PERSON
          - EMAIL_ADDRESS
          - CREDIT_CARD
      output:
        entities:
          - PERSON
          - EMAIL_ADDRESS

When we run our code in verbose mode, we see that the input is redacted:

Verbose mode logs showing the redacted email and credit card number.

With LLM Guard, you can protect sensitive data sent to the model and automatically restore it when the model responds. This relies on a local, secure vault that temporarily swaps your real data with placeholders. Let’s review a use case.

Imagine your AI assistant needs to use a tool to send a receipt to a user, but shouldn’t know their actual email address. The vault helps you hide the email from the LLM using a placeholder. When the LLM decides to trigger the email tool, it passes that placeholder back to you. Your application can then securely restore the real email address from the vault right before executing the tool.

Here is what that looks like:

from llm_guard.input_scanners import Anonymize
from llm_guard.vault import Vault
from llm_guard.output_scanners import Deanonymize

# setup a vault and scanners
vault = Vault()
anonymizer = Anonymize(vault, entity_types=["EMAIL_ADDRESS"])
deanonymizer = Deanonymize(vault)

# redact the user's prompt before sending it to the LLM
user_prompt = "Send my receipt to [email protected]"
safe_prompt = anonymizer.scan(user_prompt)[0]
print(f"What the LLM sees: {safe_prompt}")

# restore the email from the LLM's tool call request
llm_response = "Action: send_receipt, Email: [REDACTED_EMAIL_ADDRESS_1]"
restored_response = deanonymizer.scan(user_prompt, llm_response)[0]
print(f"What your local tool receives: {restored_response}")

# output:
# What the LLM sees: Send my receipt to [REDACTED_EMAIL_ADDRESS_1]
# What your local tool receives: Action: send_receipt, Email: [email protected]

Note that in this snippet, we're omitting the actual LLM API invocation and tool execution logic for brevity.

Conclusion

Masking sensitive data in AI-powered applications can be done at various stages. By cleaning your datasets with tools like Presidio, you prevent models from memorizing personal details. Orchestration tools like LangChain and LlamaIndex help you catch sensitive data as it moves through your application pipelines. Guardrails like LLM Guard and NeMo give you precise control over model inputs and outputs. Together, these tools make sure your users' data stays safe and private.

How did you like the theory?
Report a typo