Almost every Indian consumer product authenticates with a phone number and an OTP, and almost every one of them rate-limits the wrong thing. The pattern is a per-IP limit on the verification endpoint, added after someone raised brute force in a review, and considered done.
A four-digit code has ten thousand possibilities. Residential proxies cost very little per gigabyte. Per-IP limiting turns a five-second attack into a fifteen-minute one.
Limit four dimensions, not one
- Per code: five verification attempts, then the code is dead and a new one must be requested. This is the control that actually stops brute force.
- Per account: a cap on codes requested per hour, regardless of which endpoint or device asked.
- Per phone number or email: the same cap keyed to the destination, so an attacker cannot cycle through accounts to target one number.
- Per IP and per subnet: still worth having, now as a blunt instrument against volume rather than as the primary control.
Attempts per code is the one most often missing, and it is the cheapest to add: a counter alongside the code, incremented on each attempt, invalidating at the threshold.
Make the code harder to guess
- Six digits, not four. One million possibilities instead of ten thousand, at no cost to the user.
- Generate with a cryptographically secure source. Predictable codes turn brute force into arithmetic.
- Expire in five minutes and invalidate on use, on a new request, and on logout.
- One valid code at a time per destination — issuing a new one must kill the old.
Watch the cost side too
OTP abuse is not only account takeover. An unrated send endpoint is an SMS bill, and "SMS pumping" — an attacker triggering sends to numbers they profit from — is a fraud category, not a hypothetical.
- Cap sends per number, per account and globally per hour, with an alert when the global cap is approached.
- Add exponential backoff between resends: 30 seconds, then a minute, then five.
- Restrict destination country ranges to the ones you actually serve.
- Alert on the ratio of codes sent to codes verified. A healthy product sits near one; abuse pushes it into double digits.
Enforce it where it cannot be forgotten
Limits implemented in one controller protect one endpoint. The second login route, added later for the web client, will not have them.
- Enforce at the gateway or a shared middleware, keyed by a documented identifier, so new routes inherit it.
- Keep counters in a shared store, not in process memory — three instances behind a load balancer means three times your intended limit.
- Return 429 with Retry-After. It is honest, and it keeps legitimate clients well behaved.
- Log every limit breach with enough context to investigate. These are your earliest signal of a credential-stuffing campaign.
How to test it in ten minutes
- Request a code, then submit fifty wrong guesses. If the fiftieth is still accepted, the per-code limit does not exist.
- Request twenty codes for one number in a minute. Count how many actually send.
- Repeat the first test from three different IP addresses. If the limit resets each time, it is keyed only by IP.
- Send ten verification requests in parallel with the same code. Race conditions in counter increments are common and make the limit advisory.