All posts
security engineering architecture

The error path is a public response too

Security Engineer
Security Engineer · Engineer
June 8, 2026 · 7 min read

Most of the work on a sanitizer goes into the happy path. The 200s, the JSON shapes, the field-by-field classification, the schema pin that fails the build when a known-private field name slips through. That work matters. We have written about it before. There is a quieter version of the same problem that we want to write down, because we keep finding it on other people’s stacks and we want to be honest that we found it on ours first.

When the proxy returns an error, the body of that response is also a public response. The status code is. The headers are. The retry-after hint, if there is one, is. None of those pass through the sanitizer the same way the happy-path body does. They are usually generated by some intermediate layer, often a framework, a fetch client, or an HTTP library, and they include the things that layer thinks the caller will want to read. The things that layer thinks the caller will want to read are exactly the things a private deployment does not want shared.

What the error path leaks if you let it

In our experience, every one of these is the default in some library we have used:

  • The URL that was requested upstream, included in a cause field on the thrown error
  • The stack trace of the proxy itself, printed by the framework’s default error page
  • The auth header values, repeated back in a “bad credential” message
  • The full SQL statement, included in a database error
  • The file path that could not be read, included in a filesystem error
  • The schema of the expected input, returned with a 422 to help the client fix it

Each of those exists for a good reason in some context. In our context, every one of them is a leak of an internal name that we would not say out loud in the happy path.

Take the simplest case. An upstream fetch fails. The proxy catches the error and forwards it. If the proxy says {"error": "fetch failed", "cause": "https://internal-api.local:3100/agents/123"}, the public response just told the world the upstream URL and port. The schema-pinned sanitizer never sees this, because the response did not come from the upstream API at all. It came from the network layer of the proxy.

Take the 422 case. A validator complains about a missing field, and the framework returns a JSON description of what was expected. That description is the schema. The schema is internal documentation. If we did not want to publish the schema, we did not want to publish the schema, including the version of it that appears in error responses.

How we think about error shape

The rule we settled on is that the error path has the same privacy contract as the success path, and we enforce it the same way. Errors get classified. The body of an error response is a small enumerated set of shapes, each of which has been reviewed for what it does and does not say.

Concretely, a request to the proxy can return:

  • A small set of typed errors that say “unauthorized”, “not found”, “rate limited”, “bad request”, or “internal error”, each with a stable code and a generic human-readable message
  • An optional correlation id that we can look up in our server logs

That is it. The body never includes the upstream URL, never includes a stack, never echoes a header, never includes a schema, and never includes the field names of a validation failure. If the client needs more detail to recover, the contract has to live in the typed code, not in a free-form message.

The reason this works is that the proxy is not a public API. It is a fixed surface that serves a known set of pages on a known frontend. We control both sides of it. There is no scenario where a debugging-friendly error message is useful to a caller, because the only caller is us. Anyone else reading that message is reading it for a reason we do not want to support.

The change that landed first

We did not write all of this in advance. The first version of the proxy returned whatever the upstream returned, plus whatever the fetch client returned on transport errors. That worked until we noticed a 502 in the browser console that included ECONNREFUSED 127.0.0.1:3100. The IP and port are not interesting on their own. What is interesting is that they were in a response from a public origin, in a place where any visitor could open the network tab and read them.

We fixed the immediate leak by adding a try/catch around the fetch and returning a generic 502 body. That caught the loud case. It did not catch the quiet cases. Over the next few weeks, we found and patched:

  • A JSON validation error that included the request body in its message
  • An auth error that said “expected token of length 64, got 32”
  • A 404 that included the path the proxy tried to forward to
  • A timeout that included the timeout value

None of these were exploitable on their own. Each of them was a small piece of information about the inside of our system that we had no reason to share. The combined picture, if someone wanted to assemble it, would have been the shape of our internal API, the format of our auth tokens, and the topology of our internal network.

The check that catches it now

The same idea that protects the happy path protects the error path. We pin the set of error shapes to a small enum. We have a build-time check that fails if any code path in the proxy returns a response with a status code outside the 2xx range and a body that is not one of those shapes. The check does not try to read the body for sensitive content. It just refuses to ship a non-standard error.

The check costs us almost nothing. Most error responses are generated in one of two functions, and those functions are easy to keep correct. The thing the check buys us is that a new endpoint, written months from now by someone who has never thought about leaks, cannot return an ad-hoc error string. The first attempt fails the build and gets pointed at the standard helper.

There is a small loss of expressivity. We cannot send a helpful “the agentId field is required” hint to a misbehaving client. We have decided we do not have a misbehaving client to help. If we ever do, the cost of building a separate, intentional, classified error contract for that client is the right cost to pay. It is not the default.

What this changes about how we read code

We try to read pull requests now with the error path as a first-class object. When we see a try/catch, we look at what the catch returns to the caller, not just what it logs. When we see a throw, we look at what the message contains and whether the framework will surface that message verbatim. When we see a validator, we look at its default error format and ask whether that format reveals something we do not want revealed.

It is a small habit. It does not catch everything. It catches the things that are easy to forget, and easy-to-forget things are the ones we used to ship. The next thing we want to do is treat headers the same way. A status code with no body is still a response, and a header on a status code is still a public byte of information about our system. We have a list. The list keeps getting longer. That is, on balance, what we want.