← all writing tech

Your customer API is quietly leaking your margin

Two endpoints correctly hid the markup. A third, written on a different afternoon, handed it to any logged-in customer through the network tab.

Two of my three endpoints did exactly the right thing: the list view and the live tracking stream both returned only the price the customer pays, and nothing else. The third one, the order-detail endpoint, written on some different afternoon, did a generous SELECT * and cheerfully handed back what I pay the driver, my fee, and the exact markup to anyone logged in.

Not to the public. To a customer. Open the network tab on an order they placed, expand the JSON, and there’s your cost basis, line by line, for any account that bothered to look.

The leak you don’t picture

When you think “API leak,” you picture the open internet: an unauthenticated endpoint, a scraper, a list of emails on a forum. So you guard the front door. Auth on every route, a login wall, role checks. And the order-detail endpoint passed all of that. It required a valid session. It checked that the order belonged to the requesting customer. By every rule I’d written for “is this person allowed to see this order,” the answer was a confident yes.

The bug is that “this order” and “every field on this order” are two different questions, and I’d only answered the first. On a two-sided marketplace the dangerous boundary isn’t public versus private. It’s one authenticated role versus another. The customer is fully authorized to see the order. They are not authorized to see what the other side gets paid for it. Same row, same login, different audiences, and the wall between them was a SELECT I wrote carefully on two routes and lazily on a third.

Why the careful ones didn’t save me

Here’s what stung. I had already solved this. Twice. The list endpoint hand-picked its fields. The tracking stream hand-picked its fields. I knew the cost columns were sensitive, because two-thirds of my code treats them that way. The protection lived in my habits, not in the system, so the moment I wrote an endpoint on autopilot, the habit lapsed and the data walked straight out.

That’s the trap with redaction done per query. It’s correct exactly as often as you remember to be correct, which is most of the time, which is the worst possible failure rate. A boundary that holds 95 percent of the time isn’t a boundary. It’s a coin flip you keep winning until the one afternoon you don’t.

If your cost basis is protected by remembering to leave it out of each query, it isn’t protected. It’s just usually absent, and usually is not a security property.

One serializer, no exceptions

The fix wasn’t to go patch the order-detail endpoint. That just buys back the one leak I happened to find. The fix was to make the leak structurally impossible: define a single serializer that answers “what may a customer see about an order,” list the allowed fields once, and force every customer-facing route through it. No endpoint gets to choose its own fields. The cost columns aren’t omitted, they’re un-reachable from that path, because the only door to a customer response is the one that already knows they don’t belong.

A few things that made it stick:

  • Allow-list, never deny-list. I name the fields a customer may see, not the ones they may not. A new sensitive column added next year is invisible by default, which is the direction you want the mistake to fall.
  • Separate serializers per audience, not per endpoint. Customer, driver, internal ops each get one shape. The endpoint picks an audience, the audience picks the fields, and no route is trusted to do both.
  • Treat the cost columns as a boundary, not a value. They aren’t data I’d rather not show. They’re the margin that makes the business work, so leaking them is closer to publishing my pricing strategy than to a cosmetic bug.

The uncomfortable lesson is that I didn’t have a bug in one endpoint. I had an architecture where every endpoint was free to leak, and got lucky on most of them. Luck is not a serializer. Now there’s exactly one place that decides what a customer sees, and the careful version of me doesn’t have to show up every afternoon for the margin to stay hidden.

Email