Sensitive State
Which data is sensitive, where it is allowed to travel, and why a type beats a convention: a value that cannot be stringified cannot be logged by accident.
The requirement, the obvious build, and why it breaks
Every lesson starts where the work starts: someone asked for something, and the first implementation that comes to mind survives until the requirement changes.
How do I stop sensitive data ending up somewhere nobody designed it to go?
"Support engineers need enough of the customer record to help." The record holds a card reference, a national ID, a date of birth and a free-text note field customers sometimes paste their passwords into.
Mark the fields in a comment and be careful in code review. Everyone knows not to log a card number, and the linter has a rule for the obvious cases.
The leak is almost never log.info(card.number). It is log.info({ request }) during an incident, at 2am, by someone who is not thinking about card numbers because they are thinking about the outage.
- The leak is almost never
log.info(card.number). It islog.info({ request })during an incident, at 2am, by someone who is not thinking about card numbers because they are thinking about the outage. - A new field arrives on the model and inherits nothing — no comment, no lint rule, no classification — and is immediately in every response the reflection-based serialiser produces (Exposing Too Much).
- The convention does not survive its author. Six months later nobody knows whether the note field is sensitive, and the safe answer and the convenient answer differ.
- When the deletion request arrives, "where did this value go" is unanswerable, because a convention leaves no trace of the paths a value took.
What limits the solution, and what must never stop being true
This domain leads with these two. A design that ignores its constraints is not a design, and an invariant nobody named is one nothing is protecting.
- Logs are shipped to a third-party aggregator and retained for a year, so anything logged is effectively published inside the company and outside it.
- The framework serialises objects by reflection for API responses, which means a new column becomes a new response field with nobody deciding.
- A retention regime applies: some fields must be deletable on request, which is only possible if you know where they went.
- A sensitive value never appears in a log, an error message, a metric label, an analytics event or an export unless something deliberately put it there.
- Every place a sensitive value is revealed in full is recorded, with who and why.
- The set of sensitive fields is written down somewhere the code reads, not somewhere humans are expected to remember (Docs Close to Code).
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- The type owns "this cannot be printed". That is the part a human should not be responsible for, because humans are reliable at it right up until they are tired (Naming).
- One module owns revealing — the single place a wrapped value becomes a raw one, and therefore the single place an audit record can be written.
- The design of any feature that touches the record owns classifying its new fields; nothing downstream can infer sensitivity from a column name.
- Logging owns none of it, and that is the design goal: a logger that cannot leak is better than a logger that is careful (Logging at Boundaries).
- The wrapping boundary is intake: a sensitive value becomes
Redacted<T>at the moment it enters and stays wrapped everywhere else (Trust Boundaries). - The revealing boundary is a single function that requires a capability and a reason, so the audit trail is a consequence of the structure rather than an extra step someone remembers (Capability Passing).
- The emitting boundary is every outbound path — response serialisation, logs, exports, events. These are the places where "what may leave" must be an explicit list rather than a reflection over whatever exists (Designing a Module Interface).
A type that refuses to print itself
The convention version of this design says "do not log the card number". The type version says the card number does not know how to be a string. Both express the same intent; only one of them survives an engineer debugging an outage at 2am.
The important detail is where the wrapping happens. Wrapping at the persistence layer is too late — the raw value has already passed through the request body that the logging middleware serialises. It has to happen at intake, which makes this the same boundary the rest of this module keeps arriving at.
1export class Redacted<T> {2 private constructor(private readonly v: T) {}3 static atIntake<T>(v: T) { return new Redacted(v) } // boundary only4 5 toString() { return '[redacted]' }6 toJSON() { return '[redacted]' }7 [Symbol.for('nodejs.util.inspect.custom')]() { return '[redacted]' }8 9 /** the single exit. Named to be greppable, and it costs a reason. */10 reveal(who: Principal, why: string, _can: CanRevealSensitive): T {11 audit.record({ who, why, field: this.label })12 return this.v13 }14 15 /** most callers want this instead, and never unwrap at all */16 masked() { return mask(this.v) }17}18 19// the accident that used to be an incident:20log.info({ request }) // -> { card: '[redacted]', ... }Three interception points, because there are three ways a value becomes text in this runtime, and a design that covers one of them provides a false sense of coverage. The reveal signature is doing two jobs at once: it requires a capability, and it makes the audit record a consequence of the call rather than a discipline (Capability Passing).
Where a sensitive value is allowed to be
Sensitivity is easier to reason about as a lifecycle than as a label. A value is captured, wrapped, stored, occasionally revealed, sometimes emitted in masked form, and eventually purged — and the design question is which of those transitions exist.
The forbidden list is the part worth arguing about in a design review. Every real leak this module has described is one of those five transitions happening because nothing prevented it.
| From | On | To | Guard | Effect |
|---|---|---|---|---|
| Captured | intake | Wrapped | the wrapper's only constructor lives at the boundary | the raw reference goes out of scope immediately |
| Wrapped | save | Persisted | the field is in the encrypted or tokenised set | ciphertext or a token is written, never the plaintext |
| Persisted | load | Wrapped | the loader returns the wrapper type, not the plaintext | — |
| Wrapped | reveal(who, why) | Revealed | caller holds CanRevealSensitive | audit record: who, why, which field, when |
| Wrapped | render / export | Emitted | the outbound projection lists this field as maskable | last four digits, or a token |
| Revealed | scope exit | Purged | — | the unwrapped value does not outlive the operation that justified it |
| Persisted | retention expiry or deletion request | Purged | every store, index and cache is enumerated | — |
- Captured → Emitted — The raw value reaching a log line before it is wrapped is the single most common leak there is — request-logging middleware sees the body first. It is why wrapping belongs at intake and not at the model.
- Captured → Persisted — Storing the raw value skips tokenisation and, worse, leaves no record that this column ever held something sensitive, so the next engineer classifies it by guessing.
- Revealed → Emitted — An unwrapped value being serialised means the reveal was a permanent unwrap rather than a scoped one; the audit record then documents an event whose consequences it does not bound.
- Persisted → Emitted — A bulk export or warehouse sync reading storage directly bypasses the masking projection. This is the leak that gets found in an audit rather than in a review (Effect Boundaries).
- Purged → Wrapped — A purged value coming back means deletion was not deletion — usually a cache, a read replica or a backup nobody enumerated, and the promise made to the customer was false (Data Migration).
Read the forbidden rows as the design review: each one is a question to ask about a feature before it ships, and four of the five are about paths that were never thought of as outbound.
Say where each field may travel
Classification is only useful if it is specific about destinations. "Sensitive" as a single flag collapses fields that behave very differently: an email address belongs in a support view and not in an export to a partner; a card token belongs in storage and in no human-readable output at all.
A table like this is worth writing during feature design, and it takes about five minutes. Its value is not the table — it is that filling it in forces the question "who may reveal this?", which is the one nobody asks.
| Field | In the domain | In logs | In outbound payloads | Who may see it raw |
|---|---|---|---|---|
| Card reference | Token only | Never, not even masked — the id is enough | Last four, in the receipt projection only | Nobody in our code; the provider holds the number |
| National ID | Wrapped | Never | Never — not in exports, not in events | Compliance review, via reveal(), audited |
| Date of birth | Wrapped | Never (identifying in combination) | Age band, not the date | Support, for identity checks, audited |
| Email address | Plain | Hashed or id only | Yes, in customer-facing output | Support and the customer |
| Free-text note | Wrapped by default | Never — customers paste credentials here | Never, until a human has reviewed it | Support, audited |
| Customer id | Plain | Yes — this is what to log instead | Yes | Everyone; it is the debugging handle |
How to build it
Most important first.
- Wrap sensitive values in a type whose
toString,toJSONand inspection hooks return a mask. Then the accidental path — logging the whole object — produces[redacted]instead of an incident. - Make unwrapping ugly and named:
reveal(reason), not.value. A grep forreveal(is then a complete list of every place the raw value is used, which is the audit you will want during an incident (Stable Identifiers). - Make outbound shapes explicit. A response type listing its fields cannot acquire a new one by accident; a reflection over the model does it silently (Encapsulation).
- Classify at design time, in code: an annotation, a field set, a schema flag — something the serialiser and the log formatter can both read, so classification is enforced in two places from one declaration.
- Treat free text as sensitive by default. Customers paste passwords, card numbers and other people's details into note fields, and no classification of the *schema* catches that (The Requirements Nobody States).
- Log identifiers, not values. A stable customer id in a log line answers the debugging question, and the id is not the thing you are protecting (Debuggability by Design).
What the next change costs
The field this whole domain exists for. A structure is only better if it makes the change after this one cheaper — and it is worth saying which changes it does not help.
- Adding a field: the design forces a classification decision, because the serialiser needs to know whether it may leave. That is a small cost on every change and it is the whole mechanism — the alternative has no cost and no decision.
- Adding a new outbound channel — a partner export, an analytics pipeline — costs one explicit projection listing what may leave. Under the convention it costs nothing and leaks whatever exists.
- Answering "where does this value go?" costs a grep for
reveal(and a read of the projections. Under the convention it costs an investigation, and it is asked at the worst possible time. - Retrofitting onto an existing model is the expensive direction: every read site has to be adjusted. It is done field by field, worst first — card, credentials, national ID — and never as a sweep (Incremental Migration).
- Wrapped values are less pleasant to work with. Every comparison, formatting and lookup needs a method, and the friction is permanent and daily.
- Explicit outbound projections mean adding a field is two edits rather than one, forever. Teams that value velocity above all will experience this as bureaucracy, and for a low-stakes internal tool they are right.
- The type stops accidents inside your process; it does nothing about a memory dump, a database snapshot or a compromised dependency reading the heap. It is one layer and should be described as one.
What can go wrong
- The wrapper is applied to the model field and the raw value is still in the parsed request body, which is what the middleware logs. Wrapping late leaks early — the boundary has to be intake, not persistence.
reveal()spreads. Once it exists it gets called in a formatter, then in a test helper, then in a debug endpoint, and the audit trail becomes noise. The mitigation is a capability, and the capability can itself be over-granted (Least Privilege as a Design Decision).- The mask is applied to responses and forgotten in an export, a data warehouse sync or an event payload — the outbound paths nobody thinks of as outbound (Effect Boundaries).
- Everything is wrapped, including things that are not sensitive, so engineers unwrap reflexively and the type stops carrying meaning.
- Encryption at rest is treated as the answer. It protects a stolen disk and does nothing about the application logging the decrypted value, which is where the leak actually happens.
- Every module handling the record now depends on the
Redactedtype, which is deliberately a tiny, stable dependency with no behaviour to change (Stability and Dependency Direction). - The log formatter and the serialiser both depend on the classification, which means classification must not depend on either of them, or the cycle makes it impossible to reuse (Dependency Cycles).
- Revealing depends on a capability, so the set of modules that can see raw values is a list in the composition root rather than a property of who imported what (Wiring and the Composition Root).
- "Encrypt the column and we are done." Encryption at rest protects the disk. The overwhelming majority of leaks are the application handing the decrypted value to a log, a response or an export (Security Engineering owns what encryption at rest actually protects).
- "A linter rule is equivalent." A linter catches
log(card.number). It cannot catchlog(request), which is the shape almost every real leak takes. - "Redaction is a logging concern." Logging is one destination out of four. A design that redacts only in the log formatter still leaks through responses, exports and analytics events.
- "Mark everything sensitive to be safe." Then nothing is, because the escape hatch becomes routine. Classification is only useful if the sensitive set is small enough that unwrapping feels unusual.
- primitive-obsession
- shotgun-surgery
Testing it, and how it ages
- Test the accident, not the intention: assert that logging the whole request, the whole model and the whole error object produces no raw value. That is the path that actually leaks.
- A serialisation test that fails when the response contains a field not on the declared list — so adding a column breaks a test rather than shipping a leak (Contract Tests).
- Assert that
reveal()writes an audit record, and that a caller without the capability cannot compile or cannot construct — depending on what the language allows. - Round-trip the deletion path: after a deletion request, no store, index, cache or export holds the value. This test is annoying to write and is the only one that verifies the claim you made to the customer.
- Classification grows: fields that were harmless become sensitive when they are combined, and a design that treats sensitivity as per-field misses that a postcode plus a date of birth is identifying (Choosing the Model).
- Regulation moves the line, usually outward, and the code that adapts cheaply is the code where "what may leave" was already a list.
- The wrapper type tends to accumulate helpers —
maskedLast4,hashForLookup,compare— and that is healthy: each one is a use case that no longer needsreveal().
Where this applies
This domain's advice is contested more than most. These labels say what each claim is specific to — and where CONTESTED appears, the note gives the strongest form of the opposing view rather than a caricature.
- GENERALThat a value which cannot be printed cannot be printed by accident holds anywhere, though languages differ on how many printing paths there are to intercept —
toString,toJSON,__repr__,Debug, format strings and the debugger are all separate hooks. - DOMAIN-SPECIFICIn payments, health and identity the sensitive set is defined externally and non-negotiable, which makes the structure mandatory; in an internal analytics tool the same machinery is over-design, and the honest reason is consequence rather than principle.
- LANGUAGE-SPECIFICRust can make the wrapper genuinely unprintable and zero its memory on drop; TypeScript can intercept
toJSONandutil.inspectbut not a template literal; Python's__repr__override is bypassed by%son the raw field, so each language leaves a different hole and the design has to name which one it accepts.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — System Design — once the value is replicated into a warehouse, a search index and a set of backups, "delete it everywhere" becomes a distributed problem with no single point of enforcement, and the design that survives it is one where the value was tokenised before it ever fanned out.