The Number I Hid From the UI Was Wide Open in the API — Build Log #7
My app's unit-cost figure was admin-only on screen. But the API had no admin check, so any logged-in user could fetch it directly. Closing it took one line. Realizing that a 401 doesn't prove anything took longer.
My iOS task app shows the unit cost of its AI features — how much each user costs me to serve. Obviously that's for my eyes only, and it isn't shown to regular users.
One day I got suspicious and asked myself whether it was actually only visible to me. The UI turned out to be fine. The API had a hole.
What was exposed
The setup:
- "AI cost (this month)" in settings — shown or hidden based on whether the signed-in email is mine
- The cost panel in the analytics tab — admin-only screen, with a server-side admin check
GET /api/prep/cost— the API that actually returns the number
The third one was the problem. It only checked whether you were logged in. The admin check was missing entirely.
So while the number appeared nowhere in the interface, any logged-in user who hit that URL directly could retrieve how much their own usage costs me to serve.
The saving grace: the exposure was limited to that user's own figures. No one else's costs, no revenue, no aggregates. Still — knowing your unit cost means seeing the inside of the pricing, so it needed closing regardless.
Why it happened
Simple: I was thinking "it's safe because it isn't on screen."
What's displayed is decided by client code. But client code is, from the user's side, merely a suggestion. Anyone who knows the URL can call it without going through the app at all. Conditional rendering is presentation, not authorization.
I knew this in the abstract — the admin analytics screen did have a proper server-side check. The gap was in an endpoint I'd built assuming only I would ever use it. When production later filled up with real users, I never went back and added the guard.
The fix
One line. At the top of the cost handler, return 403 (forbidden — you're authenticated but not allowed) unless the caller is the developer account.
Deployed. No visible change for regular users, no extra cost.
The fix took five minutes. What took real time was verifying it.
"It returned 401, so we're fine" proves nothing
My first instinct was:
Call the API without logging in → get 401 (unauthorized — you need to sign in) → done
That is not a test. A 401 only proves that a logged-out caller gets rejected. The hole was that a logged-in ordinary user got through. No amount of logged-out probing can reach that path.
Same logic for sending a garbage token (the string that proves who you are): a fake dies at the authentication step and never reaches the permission check. Authorization bugs only reveal themselves after authentication succeeds.
So I signed in with the ordinary-user account I keep for app review, obtained a real token, and called production.
| How I called it | Expected | Actual |
|---|---|---|
| No auth | 401 | 401 |
| Garbage token | 401 | 401 |
| Real token, ordinary user | 403 | 403 |
Only after that row is it fair to call it closed.
Sweep for the same mistake
Finding one strongly implies there are others. So I enumerated every endpoint that could return cost-like figures and checked each for an admin guard. There were four more; all of them were properly guarded.
Don't skip this step, tedious as it is. Code written under the same false assumption — "it's safe because it isn't on screen" — usually exists in more than one place.
Takeaways
- Conditional rendering is not authorization. Permissions are decided on the server.
- The endpoint you built "just for yourself" is the dangerous one. The assumption expires the day real users arrive.
- Don't settle for a 401. Authorization holes live past authentication. Test with a real token.
- Find one, sweep for the rest. Holes cluster by assumption, not by file.
FAQ
What's the actual harm in someone knowing your unit cost? It exposes the inside of your pricing — what a feature costs to run versus what you charge for it. Even when direct harm is hard to picture, that's business-decision material you'd rather not publish.
Is this worth the effort on a solo project? The size of the hole varies; the verification procedure doesn't. Set up "call it with a real ordinary-user token" once and you can reuse it forever. If anything it matters more solo, because nobody else is reviewing your code.
How did you notice? By asking "is this really only visible to me?" The person who wrote the code remembers intending to hide it, so without a deliberate prompt to doubt yourself, you won't spot it. Scheduling time to distrust your own implementation is the practical countermeasure.
The app in this post
Everything above is from building THE TASK: AI To-Do Prep (App Store, free), an iOS app I develop solo. The idea is that AI fills a written task out until it's actually actionable. The rest of the series lives in the build log.
Related reading
Comments (0)
No comments yet. Be the first to leave one.