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.
answer
- ask what the emitted object actually holds
- the modifier had no runtime representation
- spread and stringify walk own enumerable keys
- fix at the exit versus at the source
- a debugger still sees everything
basics
~20 sThe 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 linesclass 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
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.
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.
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.
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