Redacting Sensitive Fields from Structured Error Logs
The moment you start attaching rich context to error records — request bodies, headers, user objects, breadcrumb data — you also start capturing secrets you never meant to store. This how-to builds a single recursive redactor that walks an error envelope, replaces sensitive values by key name and by content pattern, and stays safe against cycles and deep nesting before the record is serialized. It sits under Structured Error Logging & Breadcrumbs and the broader Core JavaScript Error Handling & Boundaries reference, and it pairs with Scrubbing PII from Datadog RUM Error Payloads for the SDK side and How to Log Custom Error Properties Without Blooming Payloads for keeping the record small.
Symptom / Trigger
You open your log platform to triage a 500, expand the error record, and find a live credential sitting in plaintext inside the context object. A security review or a data subject access request usually surfaces it first. The stored JSON looks like this:
{
"level": "error",
"error": { "name": "TypeError", "message": "cannot read status of undefined" },
"context": {
"user": { "id": 4127, "email": "[email protected]" },
"request": {
"headers": { "authorization": "Bearer sk_live_7 hQ2r9fXm0" },
"body": { "cardNumber": "4242 4242 4242 4242" }
}
}
}
Every field after error is a liability: an email address (PII), a bearer token (a live credential), and a card number (regulated data) all persisted to a log index that dozens of engineers can search. Worse, log records are frequently replicated to a data warehouse, a cold-storage bucket, and a third-party monitoring vendor, so a single unredacted write fans out into several copies you cannot easily recall. The trigger is almost never a deliberate decision to log a secret — it is a convenience call like context: { req } that quietly drags an entire request object, headers and body included, into the serializer.
Root Cause Explanation
The leak is not a bug in any single line — it is the absence of a gate. Structured logging encourages you to attach whole objects (req.headers, the user record, a parsed body) because doing so is convenient and the fields are genuinely useful for debugging. But JSON.stringify is indifferent to meaning: it serializes an authorization header exactly as faithfully as a status code. Without a redaction pass between enrichment and serialization, whatever you attached is what gets written.
// Broken: the whole request object is attached and serialized verbatim
function logError(error, req) {
const record = {
error: { name: error.name, message: error.message },
context: { headers: req.headers, body: req.body }, // secrets ride along
};
process.stdout.write(JSON.stringify(record) + '\n'); // now on disk forever
}
A shallow fix — deleting a couple of known keys — fails the moment a secret is nested one level deeper than you checked, arrives under a different casing, or hides inside a free-text string rather than its own field. Redaction has to be recursive, case-insensitive, and content-aware.
There is a second, subtler cause: the same value can be sensitive in one shape and harmless in another. A raw authorization header is a live credential, but the string Bearer [redacted] is fine, and a user’s numeric id is safe while their email is not. A redactor cannot decide by type alone — a string might be a status message or a session token, and both arrive as typeof value === 'string'. That is why the design below uses two independent signals: the key name under which a value sits, and patterns within the value itself. Matching on either one is what lets the walker redact an apiKey field it has never seen before and also catch a token that a developer accidentally interpolated into an otherwise innocent log message.
Step-by-Step Fix
1. Define a case-insensitive deny-list of sensitive key names
// redactor.js — one regex matches any key that names a secret, any casing
const DENY_KEYS = /^(authorization|cookie|set-cookie|password|passwd|token|access[-_]?token|refresh[-_]?token|secret|api[-_]?key|client[-_]?secret|ssn|card[-_]?number)$/i;
2. Add content patterns for secrets that hide inside free-text strings
// PII and credentials can appear inside a message, not just their own field
const EMAIL = /[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-z]{2,}/gi; // scrub emails
const BEARER = /\b(sk_(?:live|test)_[a-z0-9]+|Bearer\s+[a-z0-9._-]+)/gi; // tokens
const CARD = /\b(?:\d[ -]?){13,16}\b/g; // PAN-like runs
3. Mask string content while leaving non-sensitive text intact
function scrubString(str) {
return str
.replace(EMAIL, '[email]') // [email protected] -> [email]
.replace(BEARER, '[token]') // Bearer abc.def -> [token]
.replace(CARD, '[card]'); // 4242 4242 4242 4242 -> [card]
}
4. Walk the object recursively with a depth cap and a cycle guard
function redact(value, depth = 0, seen = new WeakSet()) {
if (depth > 8) return '[MaxDepth]'; // stop runaway nesting
if (typeof value === 'string') return scrubString(value);
if (!value || typeof value !== 'object') return value; // number/bool/null pass
if (seen.has(value)) return '[Circular]'; // never revisit a node
seen.add(value);
const out = Array.isArray(value) ? [] : {};
for (const [key, v] of Object.entries(value)) {
out[key] = DENY_KEYS.test(key) ? '[Redacted]' : redact(v, depth + 1, seen);
}
return out; // a fresh, safe copy
}
5. Keep a partial tail for correlatable identifiers instead of a blank
// For fields you still want to eyeball, mask all but the last 4 characters
function maskTail(str, keep = 4) {
if (typeof str !== 'string' || str.length <= keep) return '[Redacted]';
return '*'.repeat(str.length - keep) + str.slice(-keep); // ****3820
}
6. Run the redactor as the last step before serialization
// capture.js — redact the whole envelope, then and only then serialize
import { redact } from './redactor.js';
export function writeRecord(record) {
const safe = redact(record); // scrub keys and string content
process.stdout.write(JSON.stringify(safe) + '\n'); // safe payload leaves process
return safe;
}
Steps 1 and 2 separate the two ways a secret enters a record — as its own key, or buried inside a string — and step 3 handles only the second. Step 4 is the engine: it produces a brand-new object rather than mutating the caller’s, so the live req.headers your application still needs is untouched while the logged copy is scrubbed. Returning a copy also means you can safely redact an object that other code holds a reference to. The depth cap and the WeakSet are not optional niceties; they are what keep the walker from hanging on a circular reference or blowing the stack on a pathologically nested payload, which is exactly the kind of input an error record tends to carry.
Step 5 exists for the fields you neither want fully blank nor fully visible. A session id or a truncated card can be worth keeping as a partial — enough tail to correlate two records or read back to a user, not enough to reconstruct the secret. Reach for maskTail deliberately, per field, rather than as the default: most secrets deserve a flat [Redacted], and a partial is a controlled concession, not a general policy. Step 6 nails down the single rule that makes all of this reliable — the redactor runs once, at the boundary, on the fully assembled envelope. If you scatter redaction across the call sites that build the record, you will eventually add a field that no call site remembered to scrub. One gate, at the end, over the whole object, is the invariant worth protecting.
Verification
The record that leaked at the top of this page should come out the other side unrecognizable to an attacker but still useful to an engineer. The comparison below shows the same envelope before and after the walker runs: the identifying values are gone, the structure and the debuggable fields remain.
Feed the redactor an adversarial fixture — a matching key, a wrong-cased key, PII inside a string, a nested secret, and a cycle — and assert every one is neutralized.
// redactor.test.js — run with: node --test
import test from 'node:test';
import assert from 'node:assert';
import { redact } from './redactor.js';
test('redacts keys, content, casing, nesting, and cycles', () => {
const ctx = { user: { email: '[email protected]' } };
ctx.self = ctx; // introduce a cycle
const safe = redact({
context: {
Authorization: 'Bearer sk_live_abc123', // deny-list, mixed casing
note: 'ping [email protected] now', // PII inside a string
nested: { API_KEY: 'sk_live_deep' }, // secret one level down
ctx,
},
});
assert.equal(safe.context.Authorization, '[Redacted]');
assert.equal(safe.context.note, 'ping [email] now');
assert.equal(safe.context.nested.API_KEY, '[Redacted]');
assert.equal(safe.context.ctx.self, '[Circular]'); // cycle did not hang
});
Edge Cases & Gotchas
- Redact the copy, never mutate the source. If you scrub
req.headersin place, downstream middleware that still needs the realauthorizationheader breaks. The walker above returns a fresh object for exactly this reason. - A deny-list is a blocklist and will always trail reality. Where the data model allows it, invert the rule: build the log context from an explicit allow-list of safe fields so a newly added secret is excluded by default rather than leaked until someone notices.
- Content patterns collapse error fingerprints. Once every email becomes
[email], records that differed only by address group into one — usually desirable, but it can hide a per-user spike, so keep a non-identifying discriminator like a hashed user id if you group on it. - The
[MaxDepth]and[Circular]sentinels are load-bearing. A record built from a live framework object routinely contains cycles (a socket referencing its server referencing the socket); without the guards,JSON.stringifythrows and you lose the entire error.
FAQ
Should I redact with a deny-list or an allow-list?
Prefer an allow-list wherever the shape is known — build the logged context by explicitly copying id, route, status, and other safe fields, so anything you did not name is absent by construction. Use the deny-list recursive walker for the parts whose shape you cannot predict, such as a caught error’s arbitrary cause chain or a third-party payload. In practice mature systems run both: an allow-list at the call site and the recursive redactor as a catch-all gate before serialization.
Where in the pipeline should redaction run?
In-process, as the final step before the record is serialized or handed to a transport. Redacting at the log sink means the secret already crossed the network and may have been buffered to disk by a collector agent en route. Sink-side scrubbing is a worthwhile second layer, but the only place you fully control the data is inside your own process, immediately before it leaves.
Does the recursive walk hurt performance on the hot path?
For typical error records — a few kilobytes, single-digit nesting — the walk is negligible next to the JSON.stringify that follows it. The cost only matters if you attach very large objects, which you should avoid regardless. Cap depth (as shown), keep the deny-list a single compiled regex rather than an array you loop over, and never attach whole ORM entities or request streams to a record.
Related
- Structured Error Logging & Breadcrumbs — the parent guide this redactor plugs into as its final gate
- Scrubbing PII from Datadog RUM Error Payloads — applying the same idea inside an SDK
beforeSendhook - How to Log Custom Error Properties Without Blooming Payloads — keeping the record small so there is less to redact
- Core JavaScript Error Handling & Boundaries — the reference covering the capture surfaces these records flow from