Keep PII out of app logs and error reports in Node, Python, and Nginx
For developers who need to stop personal data leaking into logs now. This shows exact code and config changes for Node/Express, Python logging, Nginx access logs, and common error-reporting hooks, plus commands to prove redaction is working end to end.
TL;DR — Personal data usually leaks into logs through request bodies, query strings, headers, and exception context. The fastest fix is to stop logging raw requests, redact known sensitive keys at the logger boundary, drop query strings from web-server logs, and add a final scrubber before error reports leave the process. Reading time: ~5 min
Goal
When you are done, your application logs and error reports will no longer contain raw email addresses, phone numbers, auth tokens, session cookies, passwords, or full request bodies, and you will be able to prove it with a test request and a log search.
Prerequisites
- Shell access to the app host or container runtime
- Permission to change application config and restart the service
- One of these stacks in use: Node.js >= 20 (
node --version), Python >= 3.11 (python3 --version), Nginx >= 1.22 (nginx -v) - A log location you can read, for example
/var/log/nginx/access.log, container logs viadocker logs, or journald viajournalctl - A staging environment strongly preferred; if changing production logging, a maintenance window if your restart process causes brief downtime
- A list of fields your app handles that must never be logged, at minimum:
password,passcode,token,access_token,refresh_token,authorization,cookie,set-cookie,email,phone,ssn
Steps
Step 1: Find where sensitive data is entering logs
Run one grep over code and one over recent logs before changing anything.
grep -RInE 'console\.(log|error)|logger\.(info|debug|error)|print\(|traceback|req\.(body|query|headers)|Authorization|Cookie|Set-Cookie' .
grep -RInE '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}|Authorization:|Cookie:|access_token=|refresh_token=|password=|phone=' /var/log/nginx /var/log 2>/dev/null | head -50
What you should see on success: file paths and line numbers showing exactly which code paths and log files currently emit request data.
Step 2: Stop logging raw requests in application code
For Node/Express with Pino, replace any request/body logging with a redacting logger.
npm install pino pino-http
// app.js
import express from 'express';
import pino from 'pino';
import pinoHttp from 'pino-http';
const app = express();
app.use(express.json({ limit: '1mb' }));
const logger = pino({
level: process.env.LOG_LEVEL || 'info',
redact: {
paths: [
'req.headers.authorization',
'req.headers.cookie',
'req.headers["set-cookie"]',
'req.body.password',
'req.body.passcode',
'req.body.token',
'req.body.access_token',
'req.body.refresh_token',
'req.body.email',
'req.body.phone'
],
censor: '[REDACTED]'
}
});
app.use(pinoHttp({ logger }));
app.post('/login', (req, res) => {
req.log.info({ route: '/login', user_id: req.body.user_id || null }, 'login attempt');
res.status(204).end();
});
For Python, use a filter that redacts structured log records and formatted messages.
# logging_setup.py
import logging
import re
PATTERNS = [
(re.compile(r'([?&](?:email|phone|password|token|access_token|refresh_token)=)[^&\s]+', re.I), r'\1[REDACTED]'),
(re.compile(r'(Authorization: )[^\s]+', re.I), r'\1[REDACTED]'),
(re.compile(r'(Cookie: )[^\n]+', re.I), r'\1[REDACTED]'),
(re.compile(r'([A-Za-z0-9._%+-]+)@([A-Za-z0-9.-]+\.[A-Za-z]{2,})'), r'[REDACTED_EMAIL]')
]
class RedactFilter(logging.Filter):
def filter(self, record):
msg = record.getMessage()
for pattern, repl in PATTERNS:
msg = pattern.sub(repl, msg)
record.msg = msg
record.args = ()
return True
handler = logging.StreamHandler()
handler.addFilter(RedactFilter())
handler.setFormatter(logging.Formatter('%(asctime)s %(levelname)s %(message)s'))
root = logging.getLogger()
root.setLevel(logging.INFO)
root.handlers = [handler]
What you should see on success: your app still logs request outcomes, but not full headers, bodies, or query strings.
Step 3: Remove query strings and sensitive headers from Nginx logs
⚠️ Reloading Nginx with a bad config will fail and can interrupt deploy pipelines. Test the config before reload.
Use a log format that records the path without the query string. $uri excludes ?key=value; $request_uri includes it.
# /etc/nginx/nginx.conf or site config
log_format main_no_pii '$remote_addr - $remote_user [$time_local] '
'"$request_method $uri $server_protocol" $status $body_bytes_sent '
'"$http_referer" "[REDACTED_UA]" rt=$request_time';
access_log /var/log/nginx/access.log main_no_pii;
Test and reload:
sudo nginx -t
sudo systemctl reload nginx
Expected success output shape:
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful
What you should see on success: new access log lines contain /path but not ?email=... or ?token=....
Step 4: Add a last-chance scrubber for uncaught exceptions and error reports
If your app sends exceptions to any external error collector, scrub before sending. The exact vendor hook varies, but the payload shape is usually a JSON event.
Node example:
function scrub(obj) {
const s = JSON.stringify(obj);
return JSON.parse(
s
.replace(/"authorization":"[^"]+"/gi, '"authorization":"[REDACTED]"')
.replace(/"cookie":"[^"]+"/gi, '"cookie":"[REDACTED]"')
.replace(/"set-cookie":"[^"]+"/gi, '"set-cookie":"[REDACTED]"')
.replace(/([?&](email|phone|password|token|access_token|refresh_token)=)[^&"]+/gi, '$1[REDACTED]')
.replace(/[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/g, '[REDACTED_EMAIL]')
);
}
process.on('uncaughtException', (err) => {
const event = scrub({ message: err.message, stack: err.stack });
console.error(JSON.stringify(event));
process.exit(1);
});
Python example:
import sys
import traceback
from logging_setup import PATTERNS
def scrub_text(s: str) -> str:
for pattern, repl in PATTERNS:
s = pattern.sub(repl, s)
return s
def excepthook(exc_type, exc_value, exc_tb):
payload = ''.join(traceback.format_exception(exc_type, exc_value, exc_tb))
sys.stderr.write(scrub_text(payload))
sys.excepthook = excepthook
What you should see on success: stack traces still arrive, but embedded request values and identifiers are redacted.
Step 5: Restart the app and generate a known-bad request
Restart using your normal service manager, then send a request containing obvious test PII so you can verify redaction.
sudo systemctl restart myapp
curl -i 'http://127.0.0.1/login?email=alice@example.com&access_token=secret123' \
-H 'Content-Type: application/json' \
-H 'Authorization: Bearer secret-token' \
-H 'Cookie: sessionid=abc123' \
--data '{"user_id":"12345","email":"alice@example.com","password":"P@ssw0rd!","phone":"+15551234567"}'
Expected output shape:
HTTP/1.1 204 No Content
X-Powered-By: Express
Date: Thu, 30 Oct 2026 12:00:00 GMT
Connection: keep-alive
Keep-Alive: timeout=5
What you should see on success: the request succeeds, and the resulting logs contain [REDACTED] or omit the sensitive fields entirely.
Verify it works
Run these checks against app logs and web-server logs.
journalctl -u myapp -n 100 --no-pager | grep -E 'alice@example.com|secret123|P@ssw0rd|\+15551234567|Bearer secret-token|sessionid=abc123' || true
grep -E 'alice@example.com|secret123|P@ssw0rd|\+15551234567|Bearer secret-token|sessionid=abc123' /var/log/nginx/access.log || true
Expected result: both commands print nothing.
If you want a positive check that redaction happened:
journalctl -u myapp -n 50 --no-pager | grep -E '\[REDACTED\]|REDACTED_EMAIL'
tail -n 5 /var/log/nginx/access.log
Expected output shape:
Oct 30 12:00:01 host myapp[1234]: {"level":30,"req":{"headers":{"authorization":"[REDACTED]","cookie":"[REDACTED]"},"body":{"email":"[REDACTED]","password":"[REDACTED]","phone":"[REDACTED]"}},"msg":"request completed"}
127.0.0.1 - - [30/Oct/2026:12:00:01 +0000] "POST /login HTTP/1.1" 204 0 "-" "[REDACTED_UA]" rt=0.012
Common pitfalls
Query strings still appear in Nginx logs
Mistake: using $request_uri in log_format.
Symptom: access logs contain lines like "GET /reset?email=alice@example.com&token=abc HTTP/1.1".
Fix: replace $request_uri with $uri, then run sudo nginx -t && sudo systemctl reload nginx.
Debug logging reintroduces request bodies
Mistake: a dev-only console.log(req.body) or logger.debug(req.headers) survived in production code.
Symptom: redaction works for normal request logs, but one code path still prints raw JSON or headers.
Fix: delete the raw log statement and log only explicit safe fields, for example { route, user_id }.
Error reports still contain PII in stack context
Mistake: only the logger was redacted; exception payloads and breadcrumbs were not.
Symptom: app logs are clean, but your external error tracker shows email, Authorization, or cookies in event context.
Fix: add the scrubber in the error-report send hook or global exception handler before serialization.
Reverse proxy logs are fixed, but app logs still leak headers
Mistake: assuming Nginx redaction covers upstream application logs.
Symptom: /var/log/nginx/access.log is clean, but journalctl -u myapp still shows authorization or cookie.
Fix: keep both layers: Nginx log-format changes and application-level redaction.
Structured logger redaction misses nested keys
Mistake: redacting req.body.password but not arrays or alternate key names like credentials.password.
Symptom: some endpoints redact correctly, others still leak values from nested objects.
Fix: add every real key path your payloads use, and include aliases like token, access_token, refresh_token, authorization, cookie, email, and phone.
This article was written by an AI system and published pending human review. Verify anything you intend to act on.
Have a project in mind?
Get an instant AI price estimate for it, or talk directly to our team.
One email a month on what we learn building with AI