Designing for Security
Five questions — what is trusted, what is untrusted, who may call this, what data is sensitive, where privilege changes — asked before implementation, because afterwards they are structural changes.
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.
Which security decisions are design decisions, made before any code exists, rather than review findings?
"Let customers export their own account data as a CSV they can download." One sentence. The security properties of that feature are decided in the next twenty minutes of design, not in the review three weeks later.
Build the feature, then have security review it before launch. That is exactly what the review gate is for, and adding security questions to every design discussion would slow everything down for the ninety percent of features where nothing is at stake.
The review can catch a missing check. It cannot ask for the authorization decision to move from nine call sites into one, because by then nine call sites exist and moving them is a project.
- The review can catch a missing check. It cannot ask for the authorization decision to move from nine call sites into one, because by then nine call sites exist and moving them is a project.
- Requirements change in the direction of more access, never less. The export ships scoped to one user; the second requirement is "admins can export for their whole organisation", and the design either has a place for that or grows an
if (isAdmin)in the query builder. - The questions that were never asked do not show up as bugs. "What is sensitive here" unasked means the CSV includes the internal risk score nobody meant to publish, and nothing is broken — it works exactly as written.
- Review finds what it can see. A structure where any module can reach any table produces no finding at all, because there is nothing in the diff to point at (Hidden Global State).
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.
- There is a security review before launch, but it looks at a finished implementation and can realistically ask for fixes, not for a different structure.
- The team has no security specialist; whatever is asked has to be answerable by an ordinary engineer in a design discussion.
- The export runs against the production database, so anything it can read, it can leak.
- A caller may read only the data it is entitled to — and "entitled" must be decided somewhere that every read path goes through.
- Untrusted input never reaches code that treats it as trusted without something in between that made it safe, and that something must be findable.
- Nothing sensitive leaves the system through a path that was not designed to carry it — including logs, error messages and exports.
Who owns what, and where the seams fall
Responsibilities decide boundaries; boundaries decide what an interface has to say.
- Someone must own the answer to "who is this caller" — one identity resolution, at one place, producing one value that everything downstream uses.
- Someone must own "may this caller do this to this object" — and it must be a module, not a habit spread through handlers (Where Invariants Live).
- The domain code owns none of it. It should be handed an already-authenticated, already-authorized request and be unable to tell where it came from.
- Whoever designs a data-shaped feature owns naming which fields are sensitive, because nobody downstream is in a position to know.
- The seam is where untrusted becomes trusted. Everything before it is suspect; everything after it may assume it is clean, and that assumption is only safe if the boundary is a single place (Trust Boundaries).
- A second seam sits at the point where a request stops being a request and becomes a query — that is where scoping by identity belongs, not in each caller.
- Neither seam is a layer in the architecture diagram. They are ordinary module boundaries chosen for what they contain (Architecture Boundaries).
The five questions, and how each one fails when skipped
These are design questions, not review questions, and the difference is what a "no" costs. Asked during design, each has a cheap structural answer. Asked in review, each is a request to change code that already exists and already has callers.
The useful moment is the same as in the rest of this domain: the step you cannot answer. "Who may call this?" met with "well, it is behind the login" is not an answer — it is a boundary nobody has drawn.
- 1What is trusted?
Name the data and callers this code is allowed to assume are already checked.
fails by Everything is half-trusted, so every function re-validates defensively and none of them can be removed, because nobody knows which check is load-bearing.
- 2What is untrusted?
Name every source outside the boundary — request bodies, webhooks, uploaded files, a partner's API response, the model's output.
fails by A source nobody classified — usually one that feels internal, like a queue message — is treated as clean (Backend Engineering catalogues the sources).
- 3Who may call this?
Decide the principal and where the decision is made, once.
fails by The check lives in the handler, so the background job, the admin tool and the export each need their own copy, and one of them will not have it.
- 4What data here is sensitive?
Name the fields, and where they are allowed to travel.
fails by It leaks somewhere nobody was looking: a log line, an error message, a CSV column, an analytics event (Logging at Boundaries).
- 5Where does privilege change?
Find the points where code starts acting with more authority than its caller had.
fails by Privilege escalation is structural rather than exploited — a service account used for convenience becomes the way everything reads everything (Least Privilege as a Design Decision).
None of these asks how an attack works. They ask what the code knows and what it is allowed to reach — which is why an engineer with no security background can answer all five.
The second requirement is where the design gets graded
The first version of a feature is almost always fine, because it does one thing for one kind of caller. Security designs are graded by the second requirement, which in practice is always "and now a wider audience needs this".
Price it the way this domain prices everything: take the change that is actually coming and count what it touches.
The self-service CSV export must also be available to organisation admins, returning every member's data — and to support engineers, returning it with the risk score removed.
Six modules, and the risky part is not the six edits. It is that ExportQueryBuilder now takes an optional organisationId, and every existing caller has to be checked for whether passing nothing still means "just me".
One new rule and one new field set. No query changed, because no query ever decided who it was for — and the support-engineer variant is a field set, not a branch in the serializer.
What review can and cannot find
It is worth being precise about why "the security review will catch it" is a weaker plan than it sounds. Review is good at finding a missing check in a diff and bad at finding a missing structure, because the missing structure is not in the diff — it is in every file that was written before.
The failures below are the recurring ones, and what they have in common is that each was a design decision that nobody experienced as a decision.
| Trigger | Symptom | Cause | Response |
|---|---|---|---|
| A second caller for existing data — a job, an export, an internal tool | The new path returns data the web path would have refused | The authorization decision lived in the HTTP handler, so a caller that is not HTTP skipped it | Move the decision to where the data is reached, not where the request arrives (Where Invariants Live). |
| A new field added to a table | It appears in a customer-facing export, an API response and a log | Serialisation is by reflection over the whole record; nothing named which fields are exportable | Make the outbound shape explicit and additive-by-decision (Backend Engineering owns the serialisation mechanics). |
| An incident, and someone adds debug logging | Tokens and personal data land in the log aggregator, retained for a year | Sensitivity was a convention in a wiki, and the object logged was the whole request | Make sensitive values a type that cannot be stringified (Sensitive State). |
| A service account created to unblock a deployment | Six months later most internal reads run as it, with full access | No design step asked where privilege changes, so the widest credential became the default one | Grant capabilities per consumer at wiring time (Capability Passing). |
| A tenant boundary added after launch | A migration touching every table and every query, run under pressure | Scoping was never a value the code carried, so it has to be threaded through retrospectively | This is the classic "unlikely but catastrophic to retrofit" bet (The Cost of Change). |
How to build it
Most important first.
- Ask the five questions out loud during design: what is trusted, what is untrusted, who may call this, what data here is sensitive, and where does privilege change. They take minutes and each has a structural answer.
- Make the answer to "who may call this" a value the code carries rather than a check the code remembers. A request object with no identity on it cannot be scoped; one that carries an identity can be scoped once (Explicit State).
- Put validation at the boundary and let everything inside assume validity, so that the assumption is stated in one place instead of re-derived at every use (Invariant Leaks).
- Design the deny path first. What happens when the answer is no — a 404 or a 403, logged or silent — is a decision, and defaulting it means the code picks whichever is easiest (Designing the Happy Path Last).
- Write which fields are sensitive into the type, not into a comment. A convention that says "do not log this" is enforced by memory (Sensitive State).
- Keep the attacker model in Security Engineering and the structure here. You do not need to know how SQL injection works to decide that untrusted strings never reach a query builder.
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.
- Before: adding "admins may export for their organisation" means finding every query the export touches, adding a branch to each, and proving that no other caller of those queries picked up the wider scope. The cost scales with how many places construct a query.
- After: the same change is one new authorization rule and one scope value; the query paths are untouched because they never decided scope in the first place.
- What stays expensive either way: adding a genuinely new *kind* of principal — a service account, a partner integration, a support engineer acting as a user — because that changes what identity means, and every rule written against the old shape has to be re-read.
- The cost that is easy to miss: once authorization is one module, every team changing access rules queues behind it and its test suite. That is a real coordination cost bought deliberately (Fan-in and Fan-out).
- Asking five questions of every feature costs time on the many features where the answer is genuinely "nothing sensitive, one caller, no privilege change". That waste is real; the defence is that the questions are cheap and the omissions are not.
- Centralising the authorization decision makes it a coordination point and a single point of failure — a bug there is a bug everywhere, where scattered checks fail one at a time.
- Designing the structure without the attacker model means you will get the shape right and miss a class of attack. This module is deliberately half of the job.
What can go wrong
- The five questions become a checklist item in a template, answered "N/A" for speed, which is worse than not asking because it produces a record saying it was considered.
- Authorization is centralised and then bypassed by one path that predates it — the CSV export, the admin tool, the reporting replica — and centralisation makes that path less visible, not more (Invariant Leaks).
- The boundary is drawn but nothing enforces that calls arrive through it, so it degrades into a convention that holds until the next deadline.
- The design gets the structure right and the deny path leaks: a 404 for a missing object and a 403 for a forbidden one tells an attacker which ids exist. The mitigation itself needs designing (Error Modeling).
- Every read path now depends on the identity-carrying request, which is a deliberate, visible fan-in — and the point is that it becomes impossible to write a read path that forgot (Dependency Direction).
- The authorization module depends on the domain's notion of ownership, not the other way round; if the domain has to import the authorization module, the direction is wrong.
- Nothing in the domain should depend on the HTTP layer, or the answer to "who is calling" arrives differently in a background job and the check silently does not run (Volatile Dependencies).
- "So we need threat modelling for every ticket." No. Five questions in a design conversation is not STRIDE; the full process is worth running on a system, occasionally, and Security Engineering owns it (Security Engineering owns that process).
- "Security is the security team's job." The security team can decide policy and find bugs. They cannot restructure your modules, which is where most of the durable answers live.
- "If it is internal, none of this applies." Internal is a network property, not a trust property. Most of the expensive breaches involve a caller that was inside and should not have been able to reach that far (Least Privilege as a Design Decision).
- "This is just input validation." Validation is one of the five questions. The other four — who may call, what is sensitive, where privilege changes, what is trusted — have nothing to do with validating a string.
- god-object
- shotgun-surgery
Testing it, and how it ages
- Test the deny path at the boundary, not the allow path in the handler. Almost every access-control bug is a missing denial, and a suite full of happy-path tests will not see one (What a Unit Is).
- Write one test per read path asserting that a caller with no identity gets nothing — it is the cheapest way to notice the path that was added later and forgot.
- Test that the export contains exactly the fields the design named as exportable, so adding a column to a table does not silently add it to a customer-facing file.
- Property-style: for two users A and B, no request made as A ever returns a row belonging to B. That is one test that covers paths nobody enumerated (Property-Based Testing).
- The first extension is always a wider scope — organisation, team, support staff. A design that treats scope as a value survives it; one that treats it as a boolean does not.
- The second is a new channel: the same data via API, via a webhook, via an export to a partner. Each is a new path past the boundary, and this is where centralised designs quietly acquire exceptions (Stable Boundaries).
- Eventually the rules become complex enough to want a policy engine, and the honest signal for that is not complexity but *auditability* — when someone asks "who can see this?" and reading the code cannot answer it (policy engines are Security Engineering's subject, not ours).
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.
- GENERALThe five questions are about structure — which module knows what, and which call sites decide access — so they apply equally to a Rails monolith, a Go service and a browser extension; only the enforcement mechanism differs.
- DOMAIN-SPECIFICIn a regulated domain — health, payments, anything with a data-protection regime — "what is sensitive" has a legal answer that outranks engineering judgement and forces structure like field-level audit that would be over-design elsewhere.
- SCALE-SPECIFICWith one service and three engineers, a convention plus review genuinely holds; the argument for structural enforcement gets stronger with every additional team, because a convention is only as strong as the newest engineer's knowledge of it.
Where the depth lives
This domain teaches the codebase-level structure and hands the rest off.
- — Testing & Reliability Engineering — the "no request as A ever returns B's rows" property is the kind of assertion that belongs in a suite that runs on every change, and choosing how to generate the cases is a testing-strategy question this domain assumes.