secret redaction
How toJSON() decides what metadata is safe to serialize, and why the key match is substring-based and fails closed.
Overview
KitError.toJSON() is the boundary between an error's metadata and whatever reads the response
body: an HTTP client, a log aggregator, a webhook receiver. Its job is narrow but
security-critical: strip anything that looks like a secret, keep everything else.
throw new AppError('STORAGE_FAILED', {
cause: 'connection refused',
token: 'sk_live_abc123',
} as never) // steps outside STORAGE_FAILED's declared shape on purpose. See the callout below
// .toJSON() -> { ok: false, error: { code: 'STORAGE_FAILED', status: 500, cause: 'connection refused' } }
// 'token' is gone; 'sk_live_abc123' never reaches the response.
meta is free-form. ErrorKit.Args only constrains its shape, not its values. If you pass
a raw driver error, a request object, or anything else that might carry a credential into
meta, scrubMeta is what stops it from reaching a client. Reviewing what you pass as meta is
still worth doing; redaction is a backstop, not a reason to be careless upstream.The matching rule
const SECRET_KEY =
/secret|password|passphrase|plaintext|token|hash|salt|signature|credential|private|otp|recovery|apikey|api_key/i
This is a substring match, case-insensitive, not an exact-name allowlist or denylist. A key
named authToken, TokenHash, userSecret, or oldPassword is stripped, because each one
contains a matched substring.
The rule is deliberately permissive in what it catches (it will also strip an innocent key like
tokenCount) over being permissive in what it lets through. A false positive costs a caller a
field they have to rename; a false negative leaks a credential. Given that trade, this package
takes the field.
Depth and cycles
scrubMeta walks arrays and plain objects recursively, up to a depth cap of 8. Past the cap,
the entire subtree is replaced by the string '[depth-cap]', not returned as-is, and not
walked further. Returning an unwalked subtree past the cap would defeat the whole guarantee:
anything nested one level deeper than whatever depth an attacker (or an accidental circular
structure) constructs would carry its secrets out in full while the redaction still appeared to
have run.
Date instances pass through unchanged rather than being walked as plain objects (they have no
own enumerable string keys Object.entries would find secret-shaped names in anyway, and
walking them can produce surprising output on some engines).
A circular reference does not throw. The depth cap terminates the walk before a true infinite recursion, since a cycle by definition keeps nesting past any fixed depth.
What is not covered
scrubMeta only ever sees .meta, the object you pass as the error constructor's second
argument. It does not touch:
message: always equals the error'scode, by design (see API reference). There is no free-text message field to leak through.cause: set byasError/rethrowErrorto the original wrapped value, and is not part oftoJSON()'s output at all. If you logerr.causedirectly (for internal diagnostics, not a client response), it is not redacted, which is the whole reason it exists as a separate channel from the client-facing body.- Anything outside
meta: a custom subclass adding its own enumerable properties will not have them scrubbed unless it also overridestoJSON()to callscrubMetaon them.
cause is the deliberate escape hatch for full-fidelity internal logging. Route it to your
logger, never to a response body.