TL;DR: If an upstream API call fails and you surface that error straight to the client, you're leaking. Log the detail server-side. Return a sanitized generic message. That's it.

The Setup

I was working on a law firm client — a practice-management system I'm building — and wiring up Google and Xero integrations.

Standard stuff: call the external API, do something with the result, return a response.

Except when those calls failed, I was doing something dumb.

The Problem

When a Google or Xero API request errored, the raw upstream response body was bubbling all the way to the UI.

Think about what's in those bodies.

OAuth token details. Internal endpoint paths. Quota strings that reveal usage patterns. Scope errors that tell you exactly what permissions the server has.

None of that belongs in front of a user — or any client that can be inspected.

I caught it because I was reviewing the security surface before shipping a new feature, not because anything blew up.

That's the dangerous kind of leak.

What Was Actually Happening

The API wrapper looked fine from the outside — catch the error, return something.

But "something" was error.response.data passed straight through.

The client was receiving Google's exact error body. Every time. All of it.

The fix was simple once I named the problem:

  • Log the full detail server-side — you want it for debugging, you just don't want it leaving the server.
  • Return a sanitized generic message to the client — enough to communicate failure, nothing more.

A boundary can't leak what you never hand it.

Same Shape, Different Place

Once I saw the pattern, I found it again immediately.

The chat route's debug logs were emitting PII — contact names, emails, query parameters — into whatever log sink was attached.

Different file. Same root cause: trusting that the logging layer stays private, when in practice logs get forwarded, shared, pasted into Slack.

And then there was findContactByEmail, where untrusted search input was flowing into a PostgREST (Supabase's database query layer) .ilike() call without sanitization — a filter injection waiting to happen.

Three vulnerabilities. One session. All the same family.

Why This Matters to Me

There's a principle I keep coming back to: a boundary can't leak what it never had.

I first wrote it down in the context of LLM prompts — if you don't want the model to leak a field, strip it from the context before the model ever sees it. Don't rely on instructions. The model cannot share what it literally doesn't have.

The same logic holds at every other boundary. API responses. Log lines. Search inputs.

The fix is never "be more careful." The fix is minimize at the source — decide what crosses the boundary, and make that decision explicit in code.

Ship the sanitized version. Log the real one where only you can see it.

That's the whole rule.