Supplement · 4 · Security, privacy, accessibility
Three Qualities You Cannot Add Later
Security, privacy and accessibility fail for the same reason: each is treated as a review at the end, when every decision that determines the answer was made months earlier. This supplement runs all three as design activities on one flow, with the artifacts that make them checkable.
Mission tie-in: lesson 0043 listed security and accessibility among the returns pilot’s drivers. A driver that never becomes a data-flow diagram, a failing test or a contract shape is a wish.
Why this is here
You reach this page when the pilot is close enough to be real. Someone asks whether the returns flow has had a security review, and the honest answer is that nobody has looked at it as a system — only at individual pull requests. Someone else asks what happens to the free-text return reasons, and the answer is “they’re in the database.” And the accessibility work is a ticket in the backlog called “a11y pass,” scheduled for the week before launch, which is precisely when the contracts that determine the answer can no longer change.
All three are architecture problems disguised as review activities. An object-level authorization defect is a decision about where authorization lives relative to the data boundary. A retention rule is a decision about what the data model records at all. And an accessible error message is a decision about whether your eligibility contract returns a structured refusal reason or an HTTP status code. Each of those decisions gets cheap treatment at design time and expensive treatment at any point after.
Knowledge: threat model the flow, not the feature
A threat-modelling workshop is 90 minutes with the people who know the system, four questions, and a whiteboard. Adam Shostack’s framing is the one to use because it is answerable by a team rather than by a specialist: what are we building? what can go wrong? what are we going to do about it? did we do a good job?
Start with the data-flow diagram, because “what can go wrong” is unanswerable without knowing what crosses which boundary. For the returns flow the table is the diagram — six flows, and the trust-boundary column is what the whole workshop turns on:
| # | Flow | Data | Trust boundary crossed |
|---|---|---|---|
| 1 | Customer → returns UI | order reference, item, reason code, free-text note | internet → application |
| 2 | Returns service → Storefront monolith (OrderLookup) | order id, customer id | service → order-of-record (another team) |
| 3 | Returns service → carrier API | recipient name, postal address, parcel weight | internal → third party, over the internet |
| 4 | Carrier → webhook endpoint | tracking id, “parcel received” event | internet → application (inbound) |
| 5 | Support agent → support console → returns store | any return in the agent’s brand | staff → customer data |
| 6 | Returns service → event bus → Payments | return id, order id, refund amount | service → service, internal |
Two flows on that list are the ones teams routinely forget. Flow 4 is inbound from a third party and is usually built as “the carrier calls us” without anyone asking who else can. Flow 5 is the internal one — staff access to customer data — which is invisible in every architecture diagram drawn from the customer’s point of view and is where a large share of real privacy incidents live.
Now apply STRIDE to each element: Spoofing, Tampering, Repudiation, Information disclosure, Denial of service, Elevation of privilege. The point of the mnemonic is not completeness; it is that it forces six different questions at each element, so a team does not spend the whole hour on the one threat somebody already had in mind.
| Flow | Threat | Existing control | Design change | Owner |
|---|---|---|---|---|
| 4 (S) | Anyone who can reach the webhook can post “parcel received” for any tracking id and trigger a refund. | none — the endpoint is open | Verify the carrier’s request signature; and receipt only advances state to received, while settlement still requires the warehouse scan event. Two independent facts, not one. | Returns |
| 5 (T) | A support agent can set the refund amount directly in the console. | agent role check | Amount is derived from the order and is not an input. The console issues a command with an actor and a reason code; the amount field is removed from the write path. | Returns |
| 5 (R) | No record of who approved a manual-review return, so a disputed refund cannot be reconstructed. | application log, 14 days | Append-only decision log: actor, timestamp, reason code, outcome. Retained with the financial record, separate from the customer’s free-text note. | Returns |
| 1 (I) | Return ids are sequential and the detail endpoint does not check ownership. | session authentication | Object-level authorization at the repository boundary; identical error for missing and forbidden. See the code below. | Returns |
| 3 (D) | One customer can request labels in a loop; every attempt is a chargeable carrier call. | none | An idempotency key per return intent, a cap of 5 label attempts per return per day, and the cost guardrail from supplement 2 alerting on spend. | Returns |
| 5 (E) | The console’s query has no brand predicate; an agent for one brand can read another’s returns. | reviewer vigilance | Brand scope set once on the connection at the composition root, plus a fitness function that fails the build on an unscoped query. | Platform |
Six findings, four of them on flows that never appear in a customer journey map. Notice also that none of the design changes is a security product; they are all ordinary design decisions — where a value is derived rather than supplied, which two facts must agree, where a predicate lives.
Authentication is not authorization
The most common serious defect in an application like this one is not a missing login. It is a login that works perfectly, followed by no check that this user may see that object. OWASP calls it broken object-level authorization, and it is the top item on their API risk list because it is trivially exploitable and invisible in every test written from the happy path.
from dataclasses import dataclass
from typing import Protocol
@dataclass(frozen=True)
class Principal:
customer_id: str
@dataclass(frozen=True)
class ReturnRequest:
id: str
owner_id: str
order_id: str
status: str
class ReturnNotFound(Exception):
"""Raised when the return does not exist OR is not the caller's."""
class ReturnRepository(Protocol):
def get(self, return_id: str) -> ReturnRequest | None: ...
def get_for_owner(self, return_id: str, customer_id: str) -> ReturnRequest | None: ...
# BEFORE. The session is genuine, so `principal` is a real authenticated
# customer. Nothing here asks whether the return belongs to them:
# RET-8831 becomes RET-8832 and you are reading a stranger's address.
def get_return(principal: Principal, return_id: str, repo: ReturnRepository):
return repo.get(return_id)
The fix has three parts, and only the first is obvious:
# AFTER.
# 1. Ownership is part of the query, not a check after the fact. A post-fetch
# `if found.owner_id != principal.customer_id` works until someone adds a
# second call site; a repository that cannot return another owner's row does not.
# 2. The same error for missing and for forbidden, so the endpoint is not an
# oracle telling an attacker which return ids exist.
# 3. The denial is logged with the principal, because a burst of them is the
# highest-signal detection you will get for free.
def get_return(principal: Principal, return_id: str, repo: ReturnRepository) -> ReturnRequest:
found = repo.get_for_owner(return_id, principal.customer_id)
if found is None:
audit.denied(actor=principal.customer_id, resource=return_id)
raise ReturnNotFound(return_id)
return found
And the test that keeps it fixed. A negative acceptance example is an abuse case written in the same form as a feature example, so it lives in the normal suite and fails the build rather than sitting in a security report:
def test_a_customer_cannot_read_another_customers_return(repo):
alice = Principal("cust-1")
mallory = Principal("cust-2")
ret = repo.create(owner_id="cust-1", order_id="ord-77")
with pytest.raises(ReturnNotFound):
get_return(mallory, ret.id, repo)
assert get_return(alice, ret.id, repo).id == ret.id
Authorization fails at scale because it is a rule that must be repeated at every call site, and repeated rules decay: someone adds an admin endpoint, a bulk export, a support console, a report. Each new path is a fresh opportunity to forget. Moving the predicate into the data boundary changes the failure mode from “forgot to check” to “cannot express the unsafe query” — the same move as making invalid states unrepresentable in the domain (lesson 0011), applied to access instead of to values.
This is why the multi-tenant fix below is scoped to the connection rather than added to each query. A control that every future developer must remember is not a control; it is a hope with a code review attached.
Multi-tenant isolation: make the predicate structural
The Storefront monolith serves three brands from one database, and the returns store inherits that. “Every query filters by brand” is a convention, and conventions are one hurried pull request away from a cross-brand disclosure. Set the scope once, where the request is authenticated, and let the data layer carry it:
class BrandScope:
"""Brand isolation is a property of the connection, not of each query.
Set once at the composition root from the authenticated principal. Every
statement on this session then carries the predicate, so a forgotten
WHERE clause cannot cross a brand boundary -- it returns nothing instead.
"""
def __init__(self, session, brand_id: str) -> None:
self._session = session
self._brand_id = brand_id
def __enter__(self):
self._session.execute(
"SET LOCAL app.current_brand = :brand", {"brand": self._brand_id}
)
return self._session
def __exit__(self, exc_type, exc, tb) -> None:
if exc_type is None:
self._session.commit()
else:
self._session.rollback()
Underneath, the database enforces it with a row-level policy comparing the row’s brand to app.current_brand. Above it, an architecture fitness function fails the build if any repository opens a session outside BrandScope. Three layers — a policy that cannot be bypassed, a scope that is set once, and a test that catches the bypass attempt — and none of them depends on a reviewer noticing.
Privacy by design: the retention table is part of the data model
The pilot carries a constraint from lesson 0045: free-text return notes are deleted 30 days after the return closes. A constraint like that is either a property of the schema or it is a paragraph in a policy document that nobody implements. Making it real starts with a table that has a purpose column, because purpose is what justifies collection at all — and several rows do not survive being asked for one.
| Data | Purpose | Retention | Deletion mechanism |
|---|---|---|---|
| Free-text return note | Improve refusal reasons; support context | 30 days after the return closes | Nightly job; the column is nullable by design so deletion is a normal update. |
| Recipient name and postal address | Carrier label generation | Not stored — read from the order at label time, passed to the carrier, never persisted by returns | n/a — the strongest possible control. |
| Return id, state, timestamps, reason code | Operations, dispute resolution, finance | 7 years, alongside the financial record | Retained; contains no free text and no contact details. |
| Support decision log (actor, reason code) | Repudiation control from the workshop | 7 years | Append-only; staff identity, not customer content. |
| Analytics events | Outcome measurement by cohort | 13 months | Pseudonymous cohort id at emit time; the join key never leaves the service. |
Row two is the design move worth copying. The carrier needs a name and an address; the returns service does not need to keep them. Reading them at label time and never persisting them removes an entire class of retention, deletion, breach and access-control work — the cheapest privacy control available is the field you decided not to store. That is a data-model decision, and it is unavailable once six months of production rows exist.
Note also what a deletion requirement does to the schema. If the note column were NOT NULL, the 30-day rule would force either a row deletion (which destroys the financial record beside it) or a sentinel value (which lies). Splitting customer free text from the operational record is what makes the retention rule expressible at all. Retention is a modelling constraint, not a cleanup job.
Accessibility is an architecture driver, not a CSS pass
The pilot commits to WCAG 2.2 AA on the critical flows. Most of that commitment is front-end work, and a specific, load-bearing minority of it is not — it constrains contracts, state and infrastructure, which means it must be decided now:
| Criterion | Architectural consequence | Evidence |
|---|---|---|
| 3.3.1 Error Identification · 3.3.3 Error Suggestion | The eligibility contract must return a structured reason_code plus human text, tied to the field at fault. An HTTP 400 with a message blob cannot be rendered as an accessible, field-associated error. | Contract test asserting every refusal path carries a code and a field. |
| 4.1.3 Status Messages | The “label being created” pending state — required anyway by the availability driver — must be a real state the server reports, announced in a live region. Accessibility and availability want the same design here. | Journey test with the carrier stubbed to be slow. |
| 3.3.7 Redundant Entry | Do not ask again for the order reference already supplied. That makes the return wizard stateful server-side rather than a stateless multi-page form. | Journey test that completes the flow without re-entering anything. |
| 3.3.8 Accessible Authentication (Minimum) | Guest-order lookup cannot be gated by a puzzle CAPTCHA. Anti-abuse has to come from rate limiting and an emailed link instead — which is a fraud trade to be decided, not a styling choice. | An ADR recording the trade and the fraud guardrail it relies on. |
| 3.2.6 Consistent Help | The route to a human sits in the same place on every step. Directly serves the −25% support-contact target: predictable help reduces the contacts that come from being lost. | Automated check on the five journey pages. |
| 1.4.3 Contrast · 2.4.7 Focus Visible · 2.5.8 Target Size | Design-system level. Fix once in the shared components and every flow inherits it; fix per page and you will regress next quarter. | Component-level checks in the design system’s own pipeline. |
Then put it in the pipeline, at three levels, because the levels catch different things:
- Automated, every build: an accessibility linter over the five critical journey pages, failing the build on serious and critical violations. This catches missing labels, contrast and structure — a genuine floor, and nothing more. Automated tools cannot evaluate whether an error message is useful or whether focus order makes sense.
- Keyboard-only walkthrough, every release: one person completes a return without touching a mouse. Fifteen minutes, and it finds the class of defect that no linter reaches.
- Screen-reader pass on the two critical flows, before the 1% rollout gate — wired into the rollout contract from lesson 0049 as an entry condition, so it cannot be skipped by being busy.
| You see this | What it really is | The move |
|---|---|---|
| Authorization checked in the controller. | A rule that must be repeated at every future call site. | Push the predicate into the repository; the unsafe query stops existing. |
| 404 for missing, 403 for forbidden. | An oracle that enumerates which ids exist. | One error for both; log the denial with the principal. |
| “Every query filters by tenant.” | A convention one hurried pull request from a disclosure. | Scope the connection once; add a fitness function. |
| PII with no stated purpose. | Data collected because it was available. | Delete the column; read it at use time instead. |
| Retention living in a policy document. | A rule the schema makes impossible to honour. | Separate free text from the operational record; nullable by design. |
| An “a11y pass” ticket before launch. | Contract-shaped decisions discovered too late to change. | Name the criteria that constrain contracts; test them in the pipeline. |
| An unauthenticated inbound webhook. | A state transition anyone on the internet can trigger. | Verify the signature; require two independent facts before money moves. |
- “We authenticate every request, so access control is handled.”
- Authentication answers who is calling. Authorization answers whether they may touch this object, and it is the second question that is missed — consistently enough that it sits at the top of OWASP’s API risk list. Every test written from the happy path passes while it is broken.
- “Threat modelling needs a security specialist.”
- It needs the people who know the data flows, which is your team. A specialist makes the session better and is not a precondition for having one. Four questions and a whiteboard beats an expert review that never gets scheduled.
- “Retention is a compliance task for later.”
- Retention is a schema property. By the time it is a compliance task, the field is
NOT NULL, three consumers read it, and honouring the rule means a migration and a coordination exercise instead of a nightly update. - “Accessibility is a front-end concern.”
- Half of it is. The other half is whether your API returns a structured refusal reason, whether a slow carrier produces a reportable pending state, and whether your anti-abuse control is a puzzle. Those are architecture decisions, and they are made long before anyone opens a stylesheet.
- “The automated accessibility check passes, so we are AA.”
- Automated tools verify a floor. Whether the error text helps, whether focus order is sane, whether a screen-reader user can complete the flow — none of it is machine-checkable. Treat a green linter as necessary and nowhere near sufficient.
When this is the wrong move
- The system holds no data anyone wants and has no users but you. An internal batch tool with three operators does not need a 90-minute STRIDE session. Spend twenty minutes on the two flows that cross a trust boundary and stop.
- Threat modelling before the design exists. Run it when there is a data-flow diagram to argue with. Held too early it produces a generic list — “consider encryption” — that changes no decision and inoculates the team against doing it properly later.
- Isolating tenants that are not really tenants. If “brands” are three internal teams with the same data-access rights, a row-level policy adds operational complexity and buys nothing. Confirm the isolation is a real requirement before making it structural.
- Retaining more because deletion is hard. The reflex when a retention rule is awkward is to widen it. If the purpose column is empty, the right change is to stop collecting — not to keep it longer under a vaguer justification.
- Full WCAG conformance on an internal admin screen with four users, this week. Prioritise the critical customer flows, and be explicit that the admin surface is a stated gap with a date — rather than pretending the whole product is AA.
- Blocking a release on a finding nobody has ranked. A threat list without likelihood and impact becomes a queue that stops delivery and trains people to route around the workshop. Rank, decide what you accept, and record the acceptance with an owner.
Skill: find the real defect
A logged-in customer changes a return id and sees another order. The best fix:
The cheapest control over the customer’s postal address is to:
Which accessibility requirement most constrains the eligibility contract?
Practice: run the workshop on your own system
- Draw six flows. For one workflow you own, list the flows and mark every trust boundary. Force yourself to include one inbound third-party call and one staff-access path; those are the two that get skipped.
- Run 90 minutes of STRIDE with two or three colleagues. One threat per element per letter, timeboxed. Record: threat, existing control, design change, owner. A workshop that produces no design change either found nothing or was not honest.
- Rank and accept. Score each finding by likelihood and impact. Fix the top three, and explicitly record acceptance — with an owner and a revisit date — for the rest. An unranked list stops delivery and gets ignored.
- Write one negative acceptance test. Take your worst authorization finding and express it as a test in your normal suite, in the same style as a feature example. It must fail before the fix and pass after.
- Build the data inventory with a purpose column. Every row without a purpose is a deletion candidate. For one of them, work out what it would cost to stop collecting it today — that number is usually smaller than expected and only grows.
- Pick the three WCAG criteria that constrain your contracts, not your CSS. Write the evidence mechanism for each, and add one of them to a release gate so it cannot be deferred by being busy.
- Which of your endpoints would leak another customer’s data if the identifier were guessed — and how would you find out today?
- Which staff-facing path in your system has more access than any customer-facing one, and when was it last looked at?
- Name one field you store whose purpose you cannot state. What is stopping you from deleting it this week?
- If a keyboard-only user tried your primary flow this afternoon, at which step would they get stuck? Do you know, or are you guessing?
Reveal: the “did we do a good job?” test
Shostack’s fourth question is the one teams skip, and it has a concrete answer. A threat-modelling session did its job if, some months later, you can point to a design that is different because of it — a field not stored, a predicate moved into a query, a webhook that now requires two independent facts before money moves — and to a test in the normal suite that would fail if someone undid it.
A findings document with no design change is a session that produced agreement rather than security. A design change with no test is a fix with a shelf life of one refactor. Both artifacts, or the workshop was a meeting.
Your win
You can run a STRIDE workshop from a data-flow table, tell authentication from object-level authorization and fix the latter at the data boundary, make tenant isolation structural rather than conventional, express a retention constraint as a schema property, and identify the accessibility criteria that constrain contracts rather than stylesheets — each with an evidence mechanism attached.
Read deeper
- Threat Modeling: Designing for Security, Adam Shostack — ch. 1 on the four questions, ch. 3 on STRIDE per element. The OWASP Threat Modeling Cheat Sheet is the short field version.
- OWASP API Security Top 10 · Broken Object Level Authorization — the defect above, ranked first, with test guidance.
- OWASP ASVS — a requirements catalogue you can select a level from and turn into acceptance criteria; and the OWASP Top 10 for the risk framing.
- WCAG 2.2 and the How to Meet WCAG quick reference — filter to AA and read the four criteria new in 2.2; three of them constrain architecture.
- Release It!, Michael Nygard — ch. 4–5 on stability patterns, and ch. 11 on security in production: the operational half of everything above.
- Building Secure and Reliable Systems, Adkins et al. (Google, free online) — least privilege, blast-radius reduction and the argument for structural rather than procedural controls.
- Course: lesson 0045 (contracts, PII lifecycle), lesson 0043 (security and operability as drivers), lesson 0049 (release gates). Reference: fitness function, blast radius.
Ask your agent-teacher to attack your data-flow table one letter of STRIDE at a time — and to say, for each suggested control, which future pull request could quietly remove it.