A few weeks ago I sat down with a full penetration test report on a tenant app I'd been building. Twenty five findings. One critical. One high. Ten medium. Twelve low. One informational.
On paper, that looks like a checklist you work through top to bottom. In practice, fixing it taught me more about how a real app actually breaks than any code review ever has. This is what it took to close it, from the mobile side.
The critical: account takeover from two "small" gaps
The forgot-password flow leaked a user's internal ID in its response. On its own, not obviously dangerous — just an identifier. The change-password flow, separately, never asked for the current password before accepting a new one.
Neither issue is dangerous in isolation. Put them together and anyone could grab another user's ID from one screen and walk straight into their account from another. No credentials needed at any point.
Fixing it meant going back to a rule I'd gotten lazy about: never let one endpoint hand out an identifier that another endpoint is willing to trust blindly. The fix wasn't complicated — require the current password on change, and stop returning the raw user ID from the recovery flow. What was hard was noticing that two individually low-risk behaviors combined into a critical one.
The IDOR that stung more than it should have
Password generation had an IDOR. Tamper the last two digits of a user ID in the request, and you're looking at someone else's name, email, and phone number.
This one stung because it wasn't a missing feature — it was a missing check. The server assumed the ID in the request belonged to the person making it. It never actually verified that. The fix was a straightforward ownership check on the backend, but it's a reminder that any endpoint accepting a user ID as a parameter needs to ask "does this ID actually belong to the caller?" every single time, not just on the endpoints that feel sensitive.
File upload: client-side validation is decoration, not defense
This is the finding that made me rethink how I test entirely. I'd been validating file type on the picker, in the UI, and calling that done.
Turns out that's decoration. A file can carry a payload completely different from what its extension claims, and if the server isn't independently checking content, it will accept it, store it, and serve it back. One uploaded PDF in testing came back flagged by multiple antivirus engines as malicious. That's not a UI bug — that's a pipeline that trusted the client end to end.
The fix required real server-side content validation (magic-byte / MIME checking, not extension checking) plus scanning on upload, not just a picker that filters what extensions get offered.
Rate limiting: absent, not weak
Rate limiting on sensitive actions — adding co-occupants, generating passwords — was fully absent, not just lenient. I confirmed it by scripting the request and firing it repeatedly with no pushback from the server.
An attacker doesn't need to be clever if the app will just let them try a thousand times. This is one of those findings that looks cosmetic on a report until you actually automate against it yourself.
The quieter findings that added up
A few more, individually less dramatic, together just as telling:
- Sensitive data — passport numbers, IBANs, visa details — sitting unmasked in plaintext in API responses that didn't need to expose them.
- Session tokens that remained valid after logout, instead of being invalidated server-side.
- A "Hotfix" debug panel left accessible from the login screen in production, dumping full request and response logs, headers, and tokens for anyone who tapped it.
None of these felt dramatic on their own. Together, they're the difference between an app that looks secure in a demo and one that actually is.
What it actually came down to
Every fix, from the critical down to the low-severity items, traced back to the same root cause: trusting what the UI promised instead of verifying what the server actually enforced.
- A required-field asterisk means nothing if the backend accepts the request without it.
- A file-type filter means nothing if the server never checks what's actually inside the file.
- A password field means nothing if the endpoint next to it will happily change the password without asking who's asking.
The work itself wasn't glamorous. It was going endpoint by endpoint, asking "what happens if I lie to this request," and closing every gap where the app just took my word for it.
The question worth asking before the report does
If you're building anything that touches identity documents, payments, or lease data, ask this before a pentest tells you the answer: does your UI enforce this rule, or does your server?
Because on mobile, the UI is never the security boundary. It's just the first place someone's going to lie to.
Top comments (1)
The passport/IBAN one is the tell that ties the rest together: if the API ships the full value and the client masks it for display, the masking is living one layer too high — "the UI hides it" just means the real number is one devtools Network tab away. Masking is a response-shaping/authorization decision, not a view concern; the server shouldn't emit a field the caller isn't cleared to see in full, and whether the frontend renders bullets over it is irrelevant to that. It's the same root as the post-logout token and the debug panel — the server trusting the client to not-look, which is just the read-side version of trusting it to not-lie. And the debug panel shouldn't be runtime-hidden in prod at all; it should be dead-code-eliminated out of the production bundle so there's nothing left to un-hide.