SecurityGENERALDOMAIN-SPECIFICLANGUAGE-SPECIFIC

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.

The question

How do I stop sensitive data ending up somewhere nobody designed it to go?

The requirement

"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.

The obvious build

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.

Why it breaks

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.

How it breaks as requirements change
  • 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.
  • 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.
RequirementConstraintsInvariantsResponsibilitiesBoundariesInterfacesStateDependenciesFailureImplementationTestsFeedbackEvolution

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.

Constraints
  • 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.
Invariants
  • 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.

Responsibilities
  • 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).
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.

Redacted<T>, and the one way out
1export class Redacted<T> {
2 private constructor(private readonly v: T) {}
3 static atIntake<T>(v: T) { return new Redacted(v) } // boundary only
4
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.v
13 }
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.

The life of one sensitive value
CapturedWrappedPersistedRevealedEmittedPurged ·
FromOnToGuardEffect
CapturedintakeWrappedthe wrapper's only constructor lives at the boundarythe raw reference goes out of scope immediately
WrappedsavePersistedthe field is in the encrypted or tokenised setciphertext or a token is written, never the plaintext
PersistedloadWrappedthe loader returns the wrapper type, not the plaintext
Wrappedreveal(who, why)Revealedcaller holds CanRevealSensitiveaudit record: who, why, which field, when
Wrappedrender / exportEmittedthe outbound projection lists this field as maskablelast four digits, or a token
Revealedscope exitPurgedthe unwrapped value does not outlive the operation that justified it
Persistedretention expiry or deletion requestPurgedevery store, index and cache is enumerated
must be impossible
  • Captured → EmittedThe 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 → PersistedStoring 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 → EmittedAn 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 → EmittedA 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 → WrappedA 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

SIMPLIFIEDA real classification also carries retention period, lawful basis and residency, and in a regulated domain it is owned outside engineering — this version keeps only the columns that change code structure, which is the part this domain is responsible for.

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.

FieldIn the domainIn logsIn outbound payloadsWho may see it raw
Card referenceToken onlyNever, not even masked — the id is enoughLast four, in the receipt projection onlyNobody in our code; the provider holds the number
National IDWrappedNeverNever — not in exports, not in eventsCompliance review, via reveal(), audited
Date of birthWrappedNever (identifying in combination)Age band, not the dateSupport, for identity checks, audited
Email addressPlainHashed or id onlyYes, in customer-facing outputSupport and the customer
Free-text noteWrapped by defaultNever — customers paste credentials hereNever, until a human has reviewed itSupport, audited
Customer idPlainYes — this is what to log insteadYesEveryone; it is the debugging handle

How to build it

Most important first.

  • Wrap sensitive values in a type whose toString, toJSON and 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 for reveal( 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.

Cost of the next change
  • 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).
What the recommended approach costs
  • 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

Failure modes
  • 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.
Dependencies, and their direction
  • Every module handling the record now depends on the Redacted type, 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).
Misreads
  • "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 catch log(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.
Smells this explains
  • primitive-obsession
  • shotgun-surgery

Testing it, and how it ages

What to test, and at which boundary
  • 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.
How this design ages
  • 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 needs reveal().

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 toJSON and util.inspect but not a template literal; Python's __repr__ override is bypassed by %s on 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.

Performancelogs-and-secrets
Domains that do not exist yet
  • 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.