Our public site reads from an internal API. The proxy in the middle has one job: decide what every browser request is allowed to see. We make that decision twice, at two different layers, and both times we use an allow-list. The pattern is consistent enough that we now describe the whole proxy as default-deny rather than calling out the individual rules.
The reason for writing this down is that allow-listing tends to lose to deny-listing in the moment. Deny-listing feels lighter. Every new piece of data the API exposes shows up in the response immediately, and the proxy only intervenes when we explicitly notice something we should hide. That is the shape of most middleware we have seen in production. It is also the shape of most data leaks we have read about.
The path layer
The naive proxy is a one-line forwarder. Receive a path from the browser, append it to the upstream URL, return whatever comes back. Our earliest version looked like that. It worked. It also meant any new internal endpoint became reachable from the public site the moment it shipped, whether we wanted it public or not.
We replaced that with a finite list. Each route the public site actually uses is matched explicitly, by exact prefix or by a small regular expression. Anything outside that list returns 404 from the proxy itself, without ever touching the upstream API. There is no catch-all rule that says “if you cannot match a known route, just pass it through.” The fallback is failure.
The cost is visible. Every time we add a page that needs new data, we have to add a route to the proxy. Forgetting to do it produces a 404 in the browser. The error is loud, and the fix is local.
We accept that friction because the alternative is invisible. A pass-through proxy that learns a new path the day the upstream API exposes one is not an accident waiting to happen, it is an accident already happening. We just have not noticed yet.
The field layer
Within each known route, we still have to decide which fields the browser sees. The instinct here is to delete the sensitive ones from the upstream response and forward what remains. A small loop, a few delete obj.token lines, done.
We do not do that either. Every sanitizer constructs a new object literal from the upstream payload, naming only the fields it intends to forward. The agents sanitizer returns { id, name, role, title, description, status, ... } and nothing else. The dashboard sanitizer keeps agents, tasks, and pendingApprovals, and silently drops the rest. The activity sanitizer maps each raw event through an explicit shape that names the actor, the action label, and the company, and lets the rest fall on the floor.
The reason is the same as the path layer, one level deeper. If we add a new field to the upstream model tomorrow, neither sanitizer will forward it without an explicit code change. A deny-list would forward it by default and rely on a human noticing before the first deploy. We have to be sure of that human every time. We prefer not to be.
There is a second benefit that took us a while to see. When the sanitizer enumerates the output shape, the sanitizer itself becomes the documentation of the public contract. A reviewer can read one function and know exactly which fields a browser can ever receive. There is no need to also read the upstream schema, the database model, or the API handler. With a deny-list, the public contract is everything the schema exposes minus the things the proxy remembers to remove, which is not a definition any reviewer can hold in their head.
When the cost shows up
This pattern is not free, and the bill comes at predictable moments.
The first is when we add a new field to a page that already renders. The natural workflow is to update the frontend component, see that the field is undefined, and feel like the proxy is in the way. It often is. The discipline is to remember that the missing field is the correct outcome until the sanitizer is also updated. Making this fail loudly in development helps. A type-checked sanitizer that returns the shape the frontend expects catches the gap at compile time, not at runtime, which is one less round of “why is this empty.”
The second is during incidents. If a sanitizer crashes on an unexpected upstream payload shape, the public site loses that route entirely. We have hit this once. The right response was to let it stay broken, fix the sanitizer, and redeploy. The wrong response would have been to relax the sanitizer into a pass-through to restore traffic. We were tempted. Failing closed is uncomfortable when the failure is visible.
The third is during reviews. Adding a new public endpoint touches more files than a deny-list approach would: the route handler, the sanitizer, sometimes a shared type definition. Reviewers see more diff, ask more questions, and occasionally push back on whether the new exposure is necessary. That review pressure is the pattern doing its job. We pay it on purpose.
What this gets us
Putting the two layers together, the proxy answers exactly two questions for every browser request. Is this path one we have explicitly listed. If yes, is each field of the response one we have explicitly listed. The composition of those two answers is the entire public surface.
This is not a sophisticated design. It is what we get when we write down what is allowed and refuse the rest. The discipline is in resisting the small accommodations that turn an allow-list into a deny-list one quiet pull request at a time. A wildcard route here, a spread operator in a sanitizer there, and within a quarter the proxy is forwarding fields nobody can name from paths nobody can list.
We keep coming back to the same observation. The shape of a security boundary is the default it applies to the cases nobody thought about. Deny-by-default protects the cases we did not write a rule for. Allow-by-default does not.