n4nAI

Redacting sensitive data in LLM request/response logs

Step-by-step guide to redacting sensitive data in LLM logs: learn how to intercept, classify, mask, and verify PII in request and response payloads safely.

n4n Team4 min read814 words

Audio narration

Coming soon — every post will get a voice note here.

Most teams wire up LLM API calls and only later realize their request and response logs are full of API keys, email addresses, and customer PII. Redacting sensitive data in LLM logs is not optional if you handle real user input or regulated data; a single unredacted completion can become a compliance incident. This guide walks through a concrete pipeline you can drop into your service today.

Step 1: Capture logs at the boundary

Centralize logging in one middleware layer instead of scattering print statements through your call sites. At the HTTP boundary you see the exact payloads sent to and received from the model, which is where leaks happen. Deep-stack logging misses headers, proxy additions, and transformed messages.

In a FastAPI app, write a middleware that clones the request body and response body, runs them through a redactor, then emits a structured log line. Keep the redaction synchronous and deterministic so the log sink never receives raw data.

import json
from starlette.middleware.base import BaseHTTPMiddleware

class RedactingLogger(BaseHTTPMiddleware):
    async def dispatch(self, request, call_next):
        body = await request.body()
        response = await call_next(request)
        resp_body = b""
        async for chunk in response.body_iterator:
            resp_body += chunk
        redacted_req = redact(json.loads(body) if body else {})
        redacted_resp = redact(json.loads(resp_body) if resp_body else {})
        logger.info("llm_proxy", extra={"req": redacted_req, "resp": redacted_resp})
        # Rebuild response stream for the client
        response.body_iterator = iter([resp_body])
        return response

The key point: redact before the logger.info call, never after. If you log first and redact later in a separate processor, a crash or misconfig leaves raw data on disk.

Step 2: Define a redaction contract

You cannot redact what you have not named. Start with three buckets:

  • Credentials: api_key, authorization, bearer tokens, internal signing secrets.
  • PII: email, phone, SSN, free-text customer input that may contain any of those.
  • Business secrets: internal account IDs, prompt templates with embedded keys, unreleased model names.

Structure your logs as explicit fields so the redactor can target paths instead of guessing. A flat message string is unredeemable; a nested object is trivial.

{
  "model": "gpt-4o",
  "messages": [{"role": "user", "content": "my email is a@b.com"}],
  "metadata": {"api_key": "sk-1234", "trace_id": "abc"}
}

Decide up front: trace_id is safe, api_key is never. Encode that policy in code, not in a wiki.

Step 3: Build a deterministic redactor

A regex-based pass is enough for 80% of cases. Extend with a deny-list of JSON keys that must always be masked, and recursively walk the structure so nested dictionaries and lists are covered.

import re

EMAIL_RE = re.compile(r"[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}")
KEY_RE = re.compile(r"sk-[A-Za-z0-9]{20,}")
DENY_KEYS = {"api_key", "authorization", "password", "token"}

def redact(obj):
    if isinstance(obj, dict):
        return {
            k: ("***REDACTED***" if k.lower() in DENY_KEYS else redact(v))
            for k, v in obj.items()
        }
    if isinstance(obj, list):
        return [redact(v) for v in obj]
    if isinstance(obj, str):
        return KEY_RE.sub("***REDACTED***", EMAIL_RE.sub("***REDACTED***", obj))
    return obj

This function is pure and unit-testable. Run it on every log record. For free-text PII beyond emails and keys, add a lightweight NER pass with a library like presidio-analyzer if your compliance scope demands it, but start with the regex and deny-list to ship fast.

Step 4: Wrap your LLM client

If you call an OpenAI-compatible endpoint directly from Python, subclass the client or use a wrapper that redacts before logging. When you route through a gateway like n4n.ai, you get per-token usage metering and automatic fallback across 240+ models, but the gateway does not mask prompt content—redacting sensitive data in LLM logs stays your responsibility at the application layer. The wrapper pattern works identically regardless of backend.

from openai import OpenAI

class RedactingClient:
    def __init__(self, base_url, api_key):
        self.client = OpenAI(base_url=base_url, api_key=api_key)

    def create_chat(self, **kwargs):
        log_payload = redact(kwargs)
        logger.info("llm_request", extra=log_payload)
        resp = self.client.chat.completions.create(**kwargs)
        logger.info("llm_response", extra=redact(resp.model_dump()))
        return resp

Swap the raw OpenAI instance for RedactingClient in your dependency injection. For async code, mirror the method with await and resp.model_dump() on the async response object.

Step 5: Handle streaming responses

Streaming breaks the simple “log the whole body” approach. You must either buffer the full stream and redact at the end, or redact each chunk (riskier for cross-chunk PII). Buffering is safer for compliance and usually acceptable because you are already holding the text in memory to yield to the caller.

async def stream_redacted(client, **kwargs):
    chunks = []
    stream = await client.chat.completions.create(stream=True, **kwargs)
    async for chunk in stream:
        chunks.append(chunk)
    full = "".join(c.choices[0].delta.content or "" for c in chunks)
    logger.info("llm_stream", extra={"content": redact(full)})
    return chunks

If you cannot buffer due to latency, redact per chunk but accept that a split email may leak half. Not worth it for most teams. Prefer buffering and log the redacted full text after the stream closes.

Step 6: Write verification tests

Redaction code rots. Lock it with a test that fails if a known secret reaches the log sink. Use caplog to capture handler output and assert on absence of raw strings.

def test_redact_emails(caplog):
    import logging
    with caplog.at_level(logging.INFO):
        logger.info("test", extra=redact({"msg": "mail me at x@y.com"}))
    assert "x@y.com" not in caplog.text
    assert "***REDACTED***" in caplog.text

def test_redact_api_key(caplog):
    with caplog.at_level(logging.INFO):
        logger.info("test", extra=redact({"api_key": "sk-abcdefghijklmnopqrstuvwx"}))
    assert "sk-abcdefghijklmnopqrstuvwx" not in caplog.text

Run these in CI on every commit. Add a property-based test with hypothesis that generates random strings containing emails and keys to catch regex drift. Redacting sensitive data in LLM logs is only trustworthy if the tests are mandatory.

Step 7: Audit and monitor redaction coverage

Log a counter of how many redactions occurred per request. If the count suddenly drops to zero on a path that normally contains PII, your redactor may be bypassed by a new field name or a refactored client.

def redact_with_count(obj):
    before = json.dumps(obj)
    after = redact(obj)
    redacted = before.count("***REDACTED***")
    logger.info("redaction_stats", extra={"count": redacted})
    return after

Wire this into your metrics pipeline. Alert on zero redactions for user-facing endpoints over a rolling window. Sample raw-vs-redacted diffs in a secure debug bucket if you need to tune the regex, but never store the raw sample long-term.

Verify success

After deploying, do three concrete things:

  1. Send a test request containing sk-test1234567890abcdef and user@domain.com through your endpoint.
  2. Inspect the emitted log line in your local file or log UI. Confirm both values appear as ***REDACTED*** and the raw strings are absent.
  3. Run grep -R "sk-" ./logs across a day of production logs. Any hit means a bypass; fix the path and add a regression test.

Redaction is a continuous control, not a one-time patch. Treat the redactor as production code: versioned, tested, and monitored. The moment you stop verifying, the logs quietly fill with the data you promised your customers you would protect.

Tagsstructured-loggingredactionsecurityllm-apis

Written by

n4n Team

The team building n4n — a single OpenAI-compatible API in front of 240+ models, with automatic fallback, load balancing and pay-per-token metering.

More from n4n Team →

All structured logging for llm apis posts →