4 min readAUTHENTICATION · RATE-LIMITING · METHODOLOGY

Brute-forcing OTPs when nothing stops you

The math on a 4 or 6 digit one-time code with no rate limit, the verification races, the IP-rotation realities, and why self-OTP bypass still matters.

The math nobody runs before dismissing it

A 4 digit code has 10,000 possibilities. With no rate limit, that is at most 10,000 requests and on average 5,000. At a modest 50 requests a second that is under two minutes, and most OTP verify endpoints are far faster than that. A 6 digit code is 1,000,000, which sounds safe until you notice the code usually lives for five or ten minutes and you can run concurrent requests. A million guesses at a few hundred parallel requests a second is well inside a typical validity window. The entropy was never the protection. The counter was.

http
POST /api/auth/verify-otp HTTP/2
Host: example.com

{"phone":"+15555550100","code":"0000"}

Increment code to 9999. If the only difference between a wrong guess and the right one is the response body, and nothing throttles you, the code is a formality.

Where the limit is and where it is not

Rate limiting is rarely all-or-nothing; it is usually present on one axis and absent on another. I test the axes independently. A limit on requests per account may do nothing against guessing one fixed code across many phone numbers. A limit per IP falls to rotation. A limit on sending OTPs says nothing about verifying them, and the verify endpoint is the one that matters. A common pattern: the send endpoint is throttled and captcha-gated, everyone tested that, and the verify endpoint right next to it counts nothing at all.

Races on verification

Even where a counter exists, it may not be atomic. If the endpoint reads the attempt count, compares the code, then increments, those steps are a time-of-check-to-time-of-use gap. Fire a synchronised burst of guesses and they can all pass the “attempts remaining” check before any of them increments it, so a limit of five becomes a limit of however many you can land in one packet. A single-packet burst of a few hundred guesses against a 6 digit code, repeated across windows, chews through the space while the counter believes you have made five attempts.

The IP-rotation reality

People assume rotation is free; it is not, and it is worth being honest about. Per-IP limits push you toward a pool of source addresses, and a real pool means proxy infrastructure with its own latency and failure rate. That added latency eats into the validity window, so rotation trades raw rate for evasion. The practical move is to find the axis that is not IP-bound first (per-code, per-account, the verify-vs-send split, the race) because those need no rotation at all. Rotation is the fallback for when a genuine per-IP limit is the only thing standing, not the opening move.

Self-OTP bypass: impactful without the victim

Here is the case that gets waved away. You do not always need to brute the victim’s code. Often the more serious bug is verifying a factor you do not control. Suppose onboarding sends an OTP to confirm a phone number for KYC or to enrol a second factor, and the verify step trusts a client-supplied field, or returns the code, or accepts a code generated for a different number:

http
POST /api/account/confirm-phone HTTP/2
Host: example.com
Authorization: Bearer <attacker_token>

{"phone":"+15555559999","code":"482013","verified":true}

If the server honours verified, or if the OTP issued for the attacker’s own number validates against an arbitrary number, the attacker confirms ownership of a phone or a second factor they never possessed. That defeats KYC, it satisfies a 2FA enrolment check, and it does it without ever touching the real owner’s device. The severity here is not “I guessed a code,” it is “the platform now believes I own an identity I do not.”

The takeaway

Do not score an OTP by its digit count. Score it by what counts your guesses. Run the math, test each rate-limit axis on its own, check whether the counter is atomic, and treat verify and send as different endpoints. And remember that the highest-impact OTP bug is frequently not a brute force at all but a verification the server trusts when it should not, which is impactful precisely because the victim is never in the loop.