Logging PII, API keys, or passwords creates compliance violations and security risks. Here's how to build automatic redaction into your logging pipeline.
Every team that logs request bodies eventually logs a password. Every team that logs headers eventually logs an API key. Every team that logs user data eventually has a GDPR conversation they weren't prepared for.
It's not malicious — it's structural. The same practice that makes debugging effective (log everything with context) creates security and compliance risks when "everything" includes sensitive data.
This guide covers what to redact, how to automate it, and how to build a logging pipeline that's useful for debugging without being a liability.
| Data type | Common log sources | Risk |
|---|---|---|
| Passwords | Login endpoints, auth middleware | Account takeover |
| API keys / tokens | Authorization headers, webhook payloads | Service impersonation |
| Credit card numbers | Payment endpoints, billing webhooks | PCI DSS violation |
| Social security / ID numbers | User registration, KYC flows | Identity theft |
| Session tokens / JWTs | Auth middleware, cookie logging | Session hijacking |
| Data type | Regulation | Notes |
|---|---|---|
| Email addresses | GDPR, CCPA | Often needed for debugging — hash or mask instead |
| Phone numbers | GDPR, CCPA | Mask: +1 *** *** 4821 |
| IP addresses | GDPR (EU considers IPs personal data) | Hash or truncate: 203.0.113.xxx |
| Full names | GDPR, CCPA | Usually unnecessary in logs |
| Physical addresses | GDPR, CCPA | Never useful for debugging |
| Data type | When to log | When to redact |
|---|---|---|
| User IDs | Always fine — internal identifiers | — |
| Order IDs | Always fine — business references | — |
| Request paths / methods | Always fine | Except when paths contain tokens (/reset?token=...) |
| Error messages | Usually fine | When they echo user input (SQL errors with query params) |
Build redaction into the logging library so it applies everywhere automatically:
import pino from 'pino'
const sensitiveKeys = ['password', 'token', 'apiKey', 'secret', 'authorization',
'creditCard', 'ssn', 'cardNumber', 'cvv', 'sessionToken']
const logger = pino({
redact: {
paths: sensitiveKeys.map(k => `*.${k}`),
censor: '[REDACTED]'
}
})
// Automatically redacted:
logger.info({ user: 'alex', password: 's3cret123' }, 'Login attempt')
// Output: {"user":"alex","password":"[REDACTED]","msg":"Login attempt"}
Pino's redact option uses fast path matching — no regex overhead on every log call.
import structlog
import re
SENSITIVE_PATTERNS = {
'password': re.compile(r'.*'),
'token': re.compile(r'.*'),
'api_key': re.compile(r'.*'),
'authorization': re.compile(r'.*'),
'credit_card': re.compile(r'\d{13,19}'),
'ssn': re.compile(r'\d{3}-?\d{2}-?\d{4}'),
}
def redact_sensitive(logger, method_name, event_dict):
for key in list(event_dict.keys()):
key_lower = key.lower()
if any(s in key_lower for s in SENSITIVE_PATTERNS):
event_dict[key] = '[REDACTED]'
return event_dict
structlog.configure(
processors=[
redact_sensitive,
structlog.processors.TimeStamper(fmt="iso"),
structlog.processors.JSONRenderer(),
]
)
Sanitize request/response objects before they reach logging:
function sanitizeHeaders(headers) {
const sanitized = { ...headers }
const redactKeys = ['authorization', 'cookie', 'x-api-key', 'x-auth-token']
for (const key of redactKeys) {
if (sanitized[key]) {
sanitized[key] = '[REDACTED]'
}
}
return sanitized
}
function sanitizeBody(body) {
if (!body || typeof body !== 'object') return body
const sanitized = { ...body }
const redactKeys = ['password', 'confirmPassword', 'currentPassword',
'token', 'secret', 'cardNumber', 'cvv', 'ssn']
for (const key of redactKeys) {
if (sanitized[key]) sanitized[key] = '[REDACTED]'
}
return sanitized
}
// In your request logging middleware:
logger.info({
method: req.method,
path: req.url,
headers: sanitizeHeaders(req.headers),
body: sanitizeBody(req.body)
}, 'Incoming request')
Catch sensitive data by format, regardless of field name. This catches cases where sensitive data ends up in unexpected fields (error messages, stack traces, free-text descriptions):
function redactPatterns(text) {
if (typeof text !== 'string') return text
return text
// Credit card numbers (13-19 digits, with or without spaces/dashes)
.replace(/\b\d{4}[\s-]?\d{4}[\s-]?\d{4}[\s-]?\d{1,7}\b/g, '[CARD_REDACTED]')
// Email addresses
.replace(/\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Z|a-z]{2,}\b/g, '[EMAIL_REDACTED]')
// JWT tokens
.replace(/eyJ[A-Za-z0-9_-]+\.eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '[JWT_REDACTED]')
// API keys (common formats)
.replace(/\b(sk|pk|api|key|token)[-_][a-zA-Z0-9]{20,}\b/gi, '[KEY_REDACTED]')
// SSN (US)
.replace(/\b\d{3}-\d{2}-\d{4}\b/g, '[SSN_REDACTED]')
}
Pattern-based redaction is a safety net, not a primary strategy. It catches things that key-based redaction misses, but it also has false positives. Use it as a second layer.
Sometimes partial data is needed for debugging. Masking preserves enough context to identify records without exposing the full value:
function maskEmail(email) {
const [local, domain] = email.split('@')
return `${local[0]}***@${domain}`
// "alex.b@gmail.com" → "a***@gmail.com"
}
function maskCard(number) {
return `****${number.slice(-4)}`
// "4242424242424242" → "****4242"
}
function maskIp(ip) {
return ip.replace(/\.\d+$/, '.xxx')
// "203.0.113.42" → "203.0.113.xxx"
}
This lets the team identify "which user had the issue?" without exposing the full email. A masked card number helps confirm "was it the Visa ending in 4242?" without logging the full number.
Redaction that isn't tested is redaction that doesn't work. Write tests that deliberately log sensitive data and verify it's removed:
describe('log redaction', () => {
it('redacts passwords from log output', () => {
const output = captureLogOutput(() => {
logger.info({ username: 'alex', password: 'secret123' }, 'Login')
})
expect(output).toContain('alex')
expect(output).not.toContain('secret123')
expect(output).toContain('[REDACTED]')
})
it('redacts credit card patterns from message strings', () => {
const output = captureLogOutput(() => {
logger.info('Payment with card 4242 4242 4242 4242 failed')
})
expect(output).not.toContain('4242 4242 4242 4242')
expect(output).toContain('[CARD_REDACTED]')
})
})
Run these tests in CI. A code change that breaks redaction should fail the build, not show up in a compliance audit.
Some log management platforms support server-side redaction — sensitive data is stripped before it's stored, even if the application sends it.
LogFlow, for example, has a per-project PII masking toggle that automatically redacts common patterns (emails, IPs, card numbers) at ingestion time. This acts as a safety net when application-level redaction misses something.
Server-side redaction doesn't replace application-level redaction — it supplements it. The best approach is defense in depth: redact at the logger, redact at the middleware, and redact at the platform.
password, token, apiKey, secret, authorization)The last point is often overlooked. Run a query against actual production logs for patterns like password, secret, @gmail.com, or credit card regex. The results are usually surprising.
The goal isn't to log less — it's to log smart. Structured fields with automatic redaction give debugging context without security risk. Every field that reaches the log store should be there intentionally, not accidentally.
Free plan available. No credit card required. Up and running in 2 minutes.
Get started freeUnderstanding Log Levels: When to Use Debug, Info, Warn, Error, and Fatal
Log levels control what gets recorded and what gets ignored. Here's when to use each level correctly.
7 Logging Patterns for Microservices That Actually Help in Production
Microservices turn one log stream into dozens. These seven patterns make distributed debugging possible.
ClickHouse vs Elasticsearch for Log Storage: Cost, Speed, and Trade-offs
ClickHouse uses 5-10x less storage and queries faster. Elasticsearch has better full-text search. Here's when to use each.