What Not to Log
Logs are copied into systems with weaker access controls than your database, and they are full of things that should not be there.
Your database has row-level security, encrypted columns and a short access list. Your logs are shipped to an aggregator, indexed for search, retained for a year, and readable by everyone on call. Anything in a log is effectively less protected than anything in your database.
Never log
- Passwords — including in a failed-login payload.
- Session ids, access tokens, refresh tokens, API keys.
Authorization,CookieandSet-Cookieheaders.- Card numbers, CVVs, national identifiers, health data.
- TOTP secrets, recovery codes, reset tokens.
- Full request bodies from authentication endpoints.
The accidental routes
Nobody writes logger.info(password). It arrives through:
- Logging the whole request — headers and body included.
- Error reporters that attach request context by default.
- Query logging in an ORM, with bound parameters.
- URLs —
req.originalUrlcarries the query string, and tokens end up there. - Exception messages that stringify an object containing a secret.
- A third-party SDK in debug mode.
Redact centrally
One redaction function in the logger, applied to every record, is the only approach that survives. Redacting at each call site fails the first time someone adds a logger without reading the guidance.
Log what is useful instead
Identifiers, not values. A user id rather than an email. A truncated hash of a session id, so you can correlate lines without holding a usable credential. A request id that ties everything together.
And protect the logs themselves
Access control, retention limits, and awareness that log data is in scope for the same privacy obligations as your database.
Example
// ❌ These four lines leak credentials into a system with weaker controls
logger.info({ req: req.body }, 'login attempt'); // the password
logger.info({ url: req.originalUrl }, 'request'); // ?token=... in the query
logger.error({ err, headers: req.headers }, 'failed'); // Authorization, Cookie
Sentry.captureException(err, { extra: { user } }); // the whole user object
// ✅ Identifiers, not values
logger.info({ email: mask(req.body.email), ok: false }, 'login attempt');
logger.info({ path: req.path, method: req.method }, 'request');
logger.error({ err: err.message, requestId }, 'failed');When to use it
- A year of access logs is found to contain API keys because an old client passed them as query parameters.
- An error reporter is discovered to be attaching full login request bodies, exposing plaintext passwords to everyone with dashboard access.
- A support engineer can correlate a user's requests using a truncated session hash without ever seeing a usable session id.
More examples
Redaction in the logger, not at the call site
Keeping the redacted key present rather than removing it matters for debugging: a missing field and a redacted field mean very different things.
import pino from 'pino';
// One place, applied to everything. Anything else eventually fails.
const logger = pino({
redact: {
paths: [
// Headers, in every casing the runtime might produce
'req.headers.authorization', 'req.headers.cookie',
'res.headers["set-cookie"]', 'req.headers["x-api-key"]',
'headers.authorization', 'headers.cookie',
// Body fields, at any depth
'*.password', '*.newPassword', '*.currentPassword',
'*.token', '*.accessToken', '*.refreshToken', '*.idToken',
'*.secret', '*.clientSecret', '*.apiKey', '*.privateKey',
'*.cardNumber', '*.cvv', '*.ssn', '*.totpSecret',
'*.recoveryCodes',
// And the wildcards for nested request objects
'req.body.password', 'req.body.token', 'body.password',
],
censor: '[redacted]',
remove: false, // keep the key so its ABSENCE is not the signal
},
// Never serialise a whole request or response object.
serializers: {
req: (req) => ({
id: req.id,
method: req.method,
path: req.path, // ← PATH, not originalUrl: no query string
// The route pattern, so cardinality stays bounded in your index
route: req.route?.path,
ip: maskIp(req.ip),
userAgent: req.get?.('user-agent')?.slice(0, 200),
userId: req.user?.id ?? null, // the ID, never the email
}),
res: (res) => ({ statusCode: res.statusCode }),
err: pino.stdSerializers.err,
},
});
// Correlate without holding a credential: a truncated hash of the session id.
export const sessionTag = (sid) =>
sid ? createHash('sha256').update(sid).digest('hex').slice(0, 12) : null;
logger.info({
event: 'session.used',
userId: req.user.id,
session: sessionTag(req.sid), // 'a3f2c1d4e5b6' — correlatable, useless
}, 'authenticated request');
// Mask the IP too — it is personal data in most jurisdictions.
const maskIp = (ip) => String(ip ?? '')
.replace(/\.\d+$/, '.0') // 203.0.113.42 → 203.0.113.0
.replace(/:[^:]+$/, ':0');Error reporters: the leak nobody audits
The assertion that the serialised event contains no known secret is a good general test — it catches new context fields the SDK starts attaching after an upgrade.
// Error reporting SDKs attach request context by DEFAULT, and that context is
// exactly what you spent effort keeping out of your logs.
import * as Sentry from '@sentry/node';
const SENSITIVE_HEADERS = new Set([
'authorization', 'cookie', 'set-cookie', 'x-api-key', 'x-xsrf-token',
'proxy-authorization', 'x-internal-token',
]);
const SENSITIVE_FIELD = /(password|secret|token|key|credential|ssn|cvv|card)/i;
function scrub(value, depth = 0) {
if (depth > 6 || value == null) return value;
if (Array.isArray(value)) return value.map((v) => scrub(v, depth + 1));
if (typeof value !== 'object') return value;
return Object.fromEntries(Object.entries(value).map(([k, v]) =>
[k, SENSITIVE_FIELD.test(k) ? '[redacted]' : scrub(v, depth + 1)]));
}
Sentry.init({
dsn: process.env.SENTRY_DSN,
sendDefaultPii: false, // ← off. It is on in many templates.
beforeSend(event) {
// Headers
if (event.request?.headers) {
for (const key of Object.keys(event.request.headers)) {
if (SENSITIVE_HEADERS.has(key.toLowerCase())) {
event.request.headers[key] = '[redacted]';
}
}
}
// Body, and the query string entirely
if (event.request?.data) event.request.data = scrub(event.request.data);
delete event.request?.query_string;
delete event.request?.cookies;
// The URL often carries a token — keep only the path
if (event.request?.url) {
try { event.request.url = new URL(event.request.url).pathname; } catch {}
}
// Extra context and breadcrumbs, which people attach freely
if (event.extra) event.extra = scrub(event.extra);
if (event.breadcrumbs) {
event.breadcrumbs = event.breadcrumbs.map((b) => ({ ...b, data: scrub(b.data) }));
}
// Identify the user by id only
if (event.user) {
event.user = { id: event.user.id };
}
return event;
},
});
// The same audit applies to: APM tools, request tracing, feature-flag SDKs,
// analytics libraries, and any middleware that logs. Each defaults to helpful.
// Verify it, do not assume it:
it('does not send credentials to the error reporter', () => {
const event = beforeSend({
request: {
url: 'https://dfg.com/reset?token=SECRET',
headers: { authorization: 'Bearer SECRET', 'content-type': 'application/json' },
data: { password: 'hunter2', email: '[email protected]' },
},
});
const json = JSON.stringify(event);
expect(json).not.toContain('SECRET');
expect(json).not.toContain('hunter2');
});Hunting for what is already leaking
Rotating credentials found in logs is non-negotiable: log retention means the exposure window is the retention period, not the moment you found it.
# Assume something is leaking already. Go and look.
# ── 1. Query strings in access logs ──────────────────────────────────
grep -oE '\?[^ "]*' /var/log/nginx/access.log \
| grep -iE 'token|key|secret|password|session|code=' | sort | uniq -c | sort -rn
# ── 2. Credentials in the application log ────────────────────────────
grep -iE '"(password|token|secret|apiKey|authorization)"\s*:\s*"[^"]{8,}' app.log | head
# ── 3. In the aggregator, where it hurts most ────────────────────────
# Datadog: @http.url:*token* OR @password:*
# Splunk: index=api (password=* OR token=* OR authorization=*)
# CloudWatch: fields @message | filter @message like /Bearer [A-Za-z0-9._-]{20,}/
# ── 4. A CI gate on new logging code ─────────────────────────────────
# Fail the build on obviously dangerous logging patterns.
if grep -rnE "logger\.(info|debug|warn|error)\(.*req\.(body|headers)[^.]" src/; then
echo "Do not log whole request bodies or headers"; exit 1
fi
if grep -rnE "console\.log\(.*(password|token|secret)" src/; then
echo "Credential in a console.log"; exit 1
fi
if grep -rn "req.originalUrl" src/ | grep -i log; then
echo "Log req.path, not req.originalUrl (query strings carry tokens)"; exit 1
fi
# ── 5. And if you find something ─────────────────────────────────────
# 1. Rotate every credential that appeared. They are compromised.
# 2. Purge the log index if your platform supports it.
# 3. Fix the source.
# 4. Add the case to the test above.
#
# Do NOT skip step 1. "Only our team can read the logs" is a claim about
# today's access list, not about the last twelve months of it.
Discussion