skip to content

A TypeScript service class stores an API token in a field declared `private token: string`, and the token keeps turning up in log lines and JSON responses. Explain how that happens and what change actually stops it.

level: seniorimportance: should knowfreq 33%

answer

  1. ask what the emitted object actually holds
  2. the modifier had no runtime representation
  3. spread and stringify walk own enumerable keys
  4. fix at the exit versus at the source
  5. a debugger still sees everything

basics

~20 s

The private modifier is erased at compile time, so the token is an ordinary enumerable property that JSON.stringify, object spread and structured loggers all pick up. Moving it to a #token field, or adding a toJSON that omits it, removes it from that output.

solid answer

~50 s

`private` is a type-checker rule with no runtime representation. After emit, `token` is a plain own enumerable property, so anything that walks the object — `JSON.stringify` when the instance is returned from a handler, `{ ...client }`, `Object.entries`, a structured logger serialising the object, an error reporter capturing context — sees it. The fix depends on how much you want to guarantee. Declaring it `#token` is the strongest ordinary change: a private name is not an own property, so serialisation and spread simply cannot reach it. A `toJSON()` method that returns a redacted object fixes `JSON.stringify` specifically but leaves spread and logger reflection intact. Beyond that, treat redaction at the logging and serialisation boundary as the real control, and prefer not to park long-lived secrets on objects that get logged at all — a debugger or inspector can still surface a `#` field.

code

typescript · 21 lines
typescript
class Leaky {
  private token = "secret-abc";
  baseUrl = "https://api.example.com";
}

console.log(JSON.stringify(new Leaky()));
// {"token":"secret-abc","baseUrl":"https://api.example.com"}
console.log(Object.keys({ ...new Leaky() })); // ["token","baseUrl"]

class Safe {
  #token = "secret-xyz";
  baseUrl = "https://api.example.com";

  authHeader(): string {
    return `Bearer ${this.#token}`;
  }
}

console.log(JSON.stringify(new Safe()));
// {"baseUrl":"https://api.example.com"}
console.log(Object.keys({ ...new Safe() })); // ["baseUrl"]

go deeper

for a junior

Know that private disappears when TypeScript compiles, so the field is a normal property that JSON.stringify and object spread include. Being able to point at erasure as the cause is the expected answer here.

for a middle

Explain the exits precisely - stringify, spread, Object.entries, logger reflection - and contrast the two repairs: a toJSON hook fixes serialisation only, while a #token field removes the property entirely so nothing can reflect over it.

for a senior

Diagnose it as a class of bug, not one line. Weigh fixing at the exit against not storing the secret at all, know what # costs you in subclass access and testability, and insist on redaction at the logging boundary because it also covers objects you do not own.

for a principal

Own the systemic answer: where secrets live in the process at all, how short-lived and rotatable they are, which layer performs redaction, and how you prevent the next team from re-introducing the leak - lint rules, a Secret wrapper type, and review guidance rather than one careful class.

## Why the leak happens Start from erasure. This class: ```ts class ApiClient { private token = "secret-abc"; baseUrl = "https://api.example.com"; } ``` compiles to a class whose instances have two ordinary own properties, `token` and `baseUrl`. Nothing marks one as different. Every mechanism that reflects over an object therefore includes the token: - `JSON.stringify(client)` — the classic path, when a handler returns the instance or embeds it in a response body. - `{ ...client }` — spread copies own enumerable properties. - `Object.keys` / `Object.entries` / `structuredClone`. - Structured loggers (`logger.info({ client })`) that serialise the object graph. - Error reporters that attach `this` or a closed-over object as context. The candidate who says "but it's private" has confused a checker rule with a runtime property. `private` is enforced against dot access in TypeScript source and against nothing else. ## The strongest ordinary fix: a private name ```ts class ApiClient { #token = "secret-xyz"; baseUrl = "https://api.example.com"; authHeader(): string { return `Bearer ${this.#token}`; } } JSON.stringify(new ApiClient()); // {"baseUrl":"https://api.example.com"} ``` A `#` field is stored outside the property table, so it is invisible to `Object.keys`, spread, `JSON.stringify` and `structuredClone` — not by policy but because there is no property to find. This is the single change that fixes every reflection path at once. What it costs: no `protected` tier, so subclasses cannot see it; tests must exercise it through the public API rather than the bracket hatch; and below an ES2022 target the compiler downlevels private names with WeakMaps. ## The targeted fix: `toJSON` If you cannot move the field, define a serialisation hook: ```ts class ApiClient { private token = "secret-abc"; baseUrl = "https://api.example.com"; toJSON() { return { baseUrl: this.baseUrl }; } } ``` `JSON.stringify` honours `toJSON`, so the response body is clean. Understand the limit: spread, `Object.entries`, and any logger that does its own reflection rather than going through `JSON.stringify` still see `token`. `toJSON` fixes one exit, not the class of exits. ## Other options and their tradeoffs - **Keep the secret out of the object.** Pass it per call, or read it from a provider closure, so no long-lived instance holds it. This is often the cleanest answer and removes the question entirely. - **Closure capture.** A factory function that closes over the token and returns an object with only methods gives real privacy with no class fields at all — at the cost of per-instance function allocation and a shape that inheritance cannot extend. - **A wrapper type.** Store a `Secret` object whose `toJSON` and `toString` return `"[redacted]"`, so accidental interpolation is caught too. This is defence in depth, not a boundary. - **Redaction in the logger.** A serialiser that drops keys matching `token`, `secret`, `authorization`, `password` catches everything, including secrets in objects you do not own — third-party config, request headers, and options bags. Treat this as the control that must exist regardless of what the class does. ## The judgment an interviewer is listening for Three things. First, that you name erasure as the cause rather than blaming the logger. Second, that you distinguish a *fix at the exit* (`toJSON`) from a *fix at the source* (`#token`, or not storing it), and pick the source when you can. Third, that you do not oversell `#`: it stops reflection, but a debugger, a heap snapshot or an inspector can still surface the value, and anything running in the same process can be made to reveal it. Language-level privacy is a correctness and hygiene tool; it is not a security boundary against code sharing your runtime. The security control is that secrets are short-lived, scoped, rotatable, and redacted at every serialisation boundary you own.

  • You add a toJSON() that omits the token. Where can it still leak?
    Everywhere that does not route through JSON.stringify: object spread, `Object.entries`, `structuredClone`, a logger that reflects over the object itself, an error reporter capturing context, and a debugger or heap snapshot. `toJSON` closes one exit. Moving the field to `#token`, or not storing the secret on the instance, closes the class of exits.
  • Is a #private field a security control?
    No. It is a robust language-level guarantee against reflection — nothing in JavaScript can name the field from outside the class — but code running in the same process can reach the value through a debugger, a heap snapshot, or by patching the class before it is used. Treat it as strong hygiene, and rely on short-lived, rotatable, redacted secrets for actual security.
  • How would you stop this class of bug for the whole codebase rather than one class?
    Put redaction in the shared logging and serialisation layer: a key-name denylist plus a wrapper type whose toString and toJSON return a placeholder, so accidental interpolation is caught too. That covers objects you do not own — third-party config, headers, options bags — which no amount of discipline inside your own classes can reach.

saying these in an interview costs you the question

  • Assumes private fields are skipped by JSON.stringify
  • Blames the logger rather than naming type erasure
  • Thinks toJSON also protects spread and Object.entries
  • Calls a #private field a security boundary
  • Suggests renaming the field to _token as the fix

context