The OTP We Were Never Actually Checking
We had OTP verification in the app. The backend never saw it. Any client could skip it entirely.
The endpoint was straightforward: GET /user. It returned the user profile — name, email, phone number, everything. We had OTP verification in the app before users could update sensitive fields. The flow looked reasonable on the surface.
Then someone asked a simple question during a review: "where does the server verify the OTP?"
It didn't.
What the flow actually was
The OTP check existed — but entirely in the client. The app would show the OTP input screen, the user would enter the code, the app would verify it against what the SMS service returned, and if it matched, the app would proceed. The backend never received the OTP. The backend had no verification step. It trusted the client had done it.
The server accepted the update request regardless of whether OTP verification had happened. A direct API call — no OTP screen, no verification — would go through identically. Any client, any tool, any script could update a user's email or phone number on any account it had a valid token for.
The second issue compounded it. Even if OTP had been server-side, the endpoint design made the flow skippable. The "verify OTP" and "update profile" were separate steps. Completing the first didn't gate the second. Nothing tied a completed OTP verification to a specific pending update.
Why it was invisible for so long
Because the app worked correctly. When you ran the app as a real user, the OTP screen appeared, you entered the code, and the update went through. End-to-end testing passed. QA passed. The bug only existed if you bypassed the app — and nobody was testing the API directly with hostile intent.
There was also a layer of false confidence from the fact that OTP existed at all. The checkbox said "OTP verification: yes." The question nobody asked was: where?
The fix
We removed the client-side verification entirely. The app now sends the raw OTP to the backend along with the email or phone it's meant to protect. The server verifies the OTP, and if it's valid, saves the new email or phone in the same operation. No separate "verify then update" — a single atomic endpoint that does both or does neither.
# before: separate endpoints, no server-side OTP check
# POST /user/update-phone { "phone": "+91..." } # no OTP anywhere
# after: single endpoint, server owns the verification
# POST /user/verify-and-update-phone
# { "phone": "+91...", "otp": "482910" }
#
# server: verify OTP → if valid, save phone → return success
# if invalid: return 400, phone unchangedThe OTP is now generated server-side, stored server-side (Redis, short TTL), and verified server-side. The client is just a delivery mechanism — it receives the OTP via SMS and sends it back. It never decides if verification succeeded.
What this pattern costs when you get it wrong
In this case: account takeover. A valid session token plus a direct API call equals permanent email change on someone else's account. From there: password reset to the new email, full account access. The combination of "one endpoint for everything" and "trust the client on verification" is not a theoretical risk — it's a complete attack path.
- ›OTP verification belongs on the server. Always. The client is untrusted by definition.
- ›If verification and update are separate steps, the server needs to enforce the order — not rely on the client calling them in sequence.
- ›An atomic verify-and-save is safer than two endpoints because it eliminates the window between them.
- ›Testing through the app is not sufficient. Test the API directly. Your users won't all use your app.