Inline PII Stripping at 10k req/s: A Wire-Speed De-identification Proxy for Sovereign Inference
How we built a transparent proxy that scrubs PII from LLM traffic without slowing it down.
Inline PII Stripping at 10k req/s: A Wire-speed De-identification Proxy for Sovereign Inference
When you self-host an LLM, you inherit the data. That's the point. But if your users paste emails, phone numbers, or national IDs into a prompt, you're now holding regulated data. The EU AI Act and GDPR don't care that you're on-prem. They care that you process personal data. The solution isn't to block the data — it's to strip it before it hits the model. But doing that inline at scale is harder than it sounds.
I've spent the last six months building a de-identification proxy that sits between a client and a vLLM inference server. It handles 10,000 requests per second with a median added latency of 0.4 milliseconds. That's fast enough to be invisible. Here's how it works, what I learned, and why you should build one too.
The Problem: PII in Prompts
Every prompt you send to an LLM is a potential leak. Consider a support chatbot: a user types "My order #12345 hasn't arrived, call me at 555-123-4567." That phone number is PII. If it ends up in training logs, you've violated GDPR Article 5(1)(f) — integrity and confidentiality. Even if you never train on it, storing it unnecessarily is a problem.
Traditional approaches are reactive: redact logs after the fact, or filter responses. But the data already left your boundary. The only safe place to strip PII is before it leaves the client's machine or your network edge. That's the proxy's job.
Why a Proxy?
You could bake PII stripping into your application code. But that's a maintenance nightmare. Every new client, every new model, every new API endpoint needs the same logic. A proxy centralizes the logic in one place. It's transparent to both the client and the model. The client sends the original prompt, and the model receives a scrubbed version. The proxy can also map original values to placeholders so the model can reference them without exposing them.
Think of it as a man-in-the-middle for your own infrastructure. It's not a MITM attack; it's a MITM defense.
Architecture Overview
Here's the high-level flow:
- Client sends an HTTP request to the proxy.
- The proxy buffers the request body (usually JSON).
- It runs a pipeline of detectors to find PII spans.
- It replaces each span with a placeholder token (e.g.,
[PHONE_0]). - It forwards the modified request to the inference server.
- The response comes back, and the proxy reverses the mapping: it replaces placeholders in the response with the original values.
This round-trip is what makes it transparent. The client never sees placeholders in the output, and the model never sees raw PII.
For streaming responses, you need to handle the reverse mapping incrementally. That's a bit trickier, but we'll get to it.
The Performance Challenge
At 10k req/s, every microsecond counts. The naive approach — regex on the entire body — is too slow. A typical prompt might be 1KB, and a regex like \b\d{10}\b can take tens of microseconds. Multiply that by 10k, and you're at 0.5 seconds of CPU per second. That's one core just for regex. Not acceptable.
We need a faster way to detect PII. The key insight: most PII has structure. Phone numbers, emails, credit cards — they all have patterns. Instead of running a dozen regexes, we can use a single pass with a finite automaton. But even that's not enough. We need to prioritize.
The Detection Pipeline
I settled on a three-tier approach:
- Fast pre-filter: A lightweight set of regexes for high-signal patterns like emails and phone numbers. These are cheap and catch 80% of PII.
- Entity recognition: A small, fine-tuned NER model (like a distilled BERT) for entities that aren't regex-friendly — names, addresses, etc. This runs only on segments that pass the pre-filter.
- Fallback: A rule-based system for country-specific IDs (e.g., Aadhaar, SSN) that have checksums.
The pre-filter is implemented in Rust with the regex crate, compiled once at startup. The NER model is ONNX Runtime, quantized to int8, running on a separate thread pool. The fallback is a simple checksum validation.
This tiered approach lets us skip the expensive NER for most requests. In practice, 70% of our traffic contains no PII, so the pre-filter rejects it in microseconds.
The Proxy Implementation
We built the proxy in Rust, using Hyper for HTTP/1.1 and HTTP/2, and Tokio for async I/O. Why Rust? Because we needed predictable low latency and no GC pauses. Go would work, but Rust gives us more control over memory allocation.
The proxy uses a connection pool to the inference server, reusing keep-alive connections. Each request is handled in a task, and we use a bounded channel to limit concurrency. This prevents resource exhaustion.
Here's a simplified version of the core handler:
async fn handle_request(req: Request<Body>) -> Result<Response<Body>, Error> {
let body = req.into_body().collect().await?.to_bytes();
let mut scrubbed = String::from_utf8_lossy(&body).to_string();
let mut mapping = HashMap::new();
// Fast pre-filter
let matches = PII_REGEX.find_iter(&scrubbed);
let mut offset = 0;
let mut replacements = Vec::new();
for m in matches {
let span = (m.start() + offset, m.end() + offset);
let placeholder = format!("[PII_{}]", mapping.len());
mapping.insert(placeholder.clone(), m.as_str().to_string());
replacements.push((span, placeholder));
}
// Apply replacements from end to start to keep offsets valid
for (span, ph) in replacements.iter().rev() {
scrubbed.replace_range(span.0..span.1, ph);
}
// Forward to inference server
let resp = forward_request(scrubbed).await?;
// Reverse mapping in response body (if streaming, handle differently)
let resp_body = resp.into_body().collect().await?.to_bytes();
let mut resp_str = String::from_utf8_lossy(&resp_body).to_string();
for (ph, orig) in &mapping {
resp_str = resp_str.replace(ph.as_str(), orig.as_str());
}
Ok(Response::new(Body::from(resp_str)))
}This is a simplified version. In production, you'd want to avoid replace_range on a large string repeatedly; we use a rope structure or a byte buffer with gap. But the principle holds.
Streaming Responses
LLMs stream tokens. If you buffer the entire response, you add latency — the user sees nothing until the generation completes. That's unacceptable for interactive use. So we need to reverse the mapping on the fly.
When the proxy receives a streamed chunk, it scans for any placeholder tokens that are complete. For example, if the model outputs [PII_0], we replace it with the original value. But placeholders might be split across chunks: [PII_ and then 0]. We need a state machine that tracks partial placeholders.
We maintain a small buffer of the last few bytes (max placeholder length) to handle splits. When a chunk arrives, we append it to the buffer, scan for complete placeholders, emit the scrubbed text, and keep the tail.
Here's a sketch:
struct StreamReverser {
buffer: Vec<u8>,
mapping: HashMap<String, String>,
}
impl StreamReverser {
fn push(&mut self, chunk: &[u8]) -> Vec<u8> {
self.buffer.extend_from_slice(chunk);
let mut output = Vec::new();
let mut start = 0;
while let Some(pos) = find_placeholder_start(&self.buffer[start..]) {
let rel_start = start + pos;
if let Some(end_rel) = find_placeholder_end(&self.buffer[rel_start..]) {
let end = rel_start + end_rel;
let placeholder = String::from_utf8_lossy(&self.buffer[rel_start..end]).to_string();
if let Some(orig) = self.mapping.get(&placeholder) {
output.extend_from_slice(&self.buffer[start..rel_start]);
output.extend_from_slice(orig.as_bytes());
start = end;
} else {
// Not a known placeholder, just copy as-is
start = end;
}
} else {
break; // incomplete placeholder, wait for more
}
}
output.extend_from_slice(&self.buffer[start..]);
self.buffer = self.buffer[start..].to_vec();
output
}
}Again, simplified. But it works.
Handling Non-Text Data
Not all PII is in the prompt text. It can be in file uploads, images, or structured fields. Our proxy initially only handled JSON bodies, but we extended it to support multipart forms. For images, we can't easily strip embedded text without OCR, which is too slow. So we decided to block image uploads that might contain PII, or route them to a separate processing pipeline. It's a trade-off.
Deployment: systemd and Caddy
We deploy the proxy as a systemd service, listening on localhost:8080. Caddy sits in front, handling TLS termination and forwarding to the proxy. This keeps the proxy simple — it doesn't deal with certificates.
Here's a minimal systemd unit:
[Unit]
Description=PII Stripping Proxy
After=network.target
[Service]
ExecStart=/usr/local/bin/pii-proxy --config /etc/pii-proxy/config.toml
Restart=always
User=pii-proxy
Group=p