A Request/Response Logger That Does Not Leak Secrets
The redact-by-key logger I add to every Node service before it touches production logs. Catches headers, JWTs, card numbers, and Stripe keys without paying for a SIEM scrubber.
By @nadiaali
March 6, 2026
·
Updated July 29, 2026
435 views
14
4.4 (15)
// Redact-by-key middleware for HTTP request/response logging.
// Tested with `npx tsx`. No deps.
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
const REDACT_KEYS = new Set([
'authorization', 'cookie', 'set-cookie',
'password', 'token', 'access_token', 'refresh_token',
'api_key', 'apikey', 'x-api-key',
'card', 'cvc', 'pan', 'ssn',
]);
const REDACT_PATTERNS: RegExp[] = [
/\b\d{13,19}\b/, // card-like numbers
/eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/, // JWTs
/sk_(?:test|live)_[A-Za-z0-9]{16,}/, // Stripe secret keys
];
function redact(value: Json, parentKey = ''): Json {
if (typeof value === 'string') {
if (REDACT_KEYS.has(parentKey.toLowerCase())) return '[REDACTED]';
let s = value;
for (const re of REDACT_PATTERNS) {
s = s.replace(re, '[REDACTED]');
}
return s;
}
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === 'object') {
const out: { [k: string]: Json } = {};
for (const [k, v] of Object.entries(value)) {
out[k] = REDACT_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v, k);
}
return out;
}
return value;
}
const sample: Json = {
headers: { authorization: 'Bearer eyJhbGciOiJIUzI1NiJ9.payload.sig', 'x-trace-id': 'abc' },
body: { user: 'me', card: '4242424242424242', note: 'My key is sk_test_AbCdEfGhIjKlMnOpQrSt12' },
};
console.log(JSON.stringify(redact(sample), null, 2));I have shipped this redactor in three companies because the failure mode of NOT having it is so loud: one accidental console.log(req.headers) and your bearer tokens land in CloudWatch forever. It does two passes per value. First, a key-name check against a denylist (authorization, password, token, common card-field names). Second, a regex sweep over string values for the patterns that look like secrets even when the key is benign, like a customer-supplied note containing sk_test_.... Keep both layers; key-only redaction misses the notes field every time.
// Express-style middleware that logs request + response with redaction.
// Mock req/res shapes here so the file runs standalone.
type Json = string | number | boolean | null | Json[] | { [k: string]: Json };
const REDACT_KEYS = new Set(['authorization', 'cookie', 'password', 'token']);
function redact(value: Json, parentKey = ''): Json {
if (typeof value === 'string') {
return REDACT_KEYS.has(parentKey.toLowerCase()) ? '[REDACTED]' : value;
}
if (Array.isArray(value)) return value.map((v) => redact(v));
if (value && typeof value === 'object') {
const out: { [k: string]: Json } = {};
for (const [k, v] of Object.entries(value)) {
out[k] = REDACT_KEYS.has(k.toLowerCase()) ? '[REDACTED]' : redact(v, k);
}
return out;
}
return value;
}
interface Req { method: string; path: string; headers: Record<string, string>; body: Json; }
interface Res { statusCode: number; body: Json; }
function logExchange(req: Req, res: Res, durationMs: number) {
const record = {
ts: new Date().toISOString(),
req: { method: req.method, path: req.path, headers: redact(req.headers), body: redact(req.body) },
res: { status: res.statusCode, body: redact(res.body) },
durationMs,
};
console.log(JSON.stringify(record));
}
logExchange(
{ method: 'POST', path: '/login', headers: { authorization: 'Bearer s3cret' }, body: { email: '[email protected]', password: 'hunter2' } },
{ statusCode: 200, body: { token: 'jwt.value.here', userId: 42 } },
37,
);This is the integration shape I use with Express, Fastify, and NestJS interceptors. The middleware logs ONE line per exchange (request + response + duration) so the structured-log query later is path=/login | err. Calling redact on both req.body and res.body means we are safe even when the response echoes the request (a real bug I found while writing this once). Inline timing keeps me honest about which endpoints are slowing down. The console.log(JSON.stringify(...)) is intentional, not lazy: matching pino or bunyan line-format means our log shipper does not need a parser.
// Body sampling for high-volume endpoints. Logs full body 1-in-N times,
// always logs on non-2xx, and tags every record with a stable correlation id.
let counter = 0;
const SAMPLE_RATE = 100; // log full body every 100 requests
let recentErrors = 0;
interface Req { method: string; path: string; bodyBytes: number; }
interface Res { statusCode: number; bodyBytes: number; }
function shouldKeepBody(res: Res): boolean {
counter++;
if (res.statusCode >= 400) return true; // always on errors
return counter % SAMPLE_RATE === 0; // sample success cases
}
function correlationId(): string {
return Math.random().toString(36).slice(2, 10);
}
function logSampled(req: Req, res: Res, sampledBody: { req: unknown; res: unknown } | null) {
const record = {
cid: correlationId(),
method: req.method,
path: req.path,
status: res.statusCode,
reqBytes: req.bodyBytes,
resBytes: res.bodyBytes,
sampled: sampledBody !== null,
...(sampledBody ?? {}),
};
if (res.statusCode >= 400) recentErrors++;
console.log(JSON.stringify(record));
}
// Simulate 5 traffic events.
for (let i = 0; i < 5; i++) {
const req = { method: 'POST', path: '/checkout', bodyBytes: 1200 };
const res = { statusCode: i === 3 ? 500 : 200, bodyBytes: 64 };
const keep = shouldKeepBody(res);
logSampled(req, res, keep ? { reqBody: '<…redacted body…>', resBody: '<…redacted body…>' } : null);
}
console.log('errors-this-batch:', recentErrors);Once /checkout hits 1k req/sec, logging every body costs more than the rest of the service combined. The fix is sampling: keep the line for every request (cheap), but only attach the redacted body 1-in-N times AND always on errors. The correlationId ties the sampled-body line back to the unsampled lines via your trace ID. We tuned SAMPLE_RATE per-route in production; checkout is 100, profile reads are 1000, internal health pings are 0 (just a counter). Reach for this the moment your log bill becomes a topic at standup.
