Skip to main content

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.

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's code, by design (see API reference). There is no free-text message field to leak through.
  • cause: set by asError/rethrowError to the original wrapped value, and is not part of toJSON()'s output at all. If you log err.cause directly (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 overrides toJSON() to call scrubMeta on them.