The OTP That Never Dies
An OTP with no rate limit, no expiry, and a code that never rotates makes brute force a certainty. Here is why it happens and how to fix it.
What this is
A one-time password is only “one-time” if the system enforces it. This pattern is the one-time-code verify endpoint that enforces almost nothing: no rate limit, no lockout, no expiry, and often a code that does not rotate between attempts, all sitting over a small code space of 4 to 6 digits. Any one of those gaps weakens the code. Together they make brute force not a risk but a certainty. An attacker does not need to be clever. They need to count.
The subtle killer is the code that persists across attempts. Even a short-lived code is a moving target - guess too slowly and it expires out from under you. A code that never rotates and never expires removes the race against the clock entirely. It sits still while the attacker walks the whole space of possible values, and small spaces are very small: a 4-digit code is 10,000 options, a script’s work of a few seconds.
Why it keeps happening
An OTP feels like security theater’s opposite - it is a real second factor, a code to a device the user holds. So it gets treated as done the moment it works. The developer requests a code, types it in, gets in, and moves on. The verify endpoint’s happy path is trivial to build and trivial to test, and it passes every manual check.
What the manual check never does is send the wrong code ten thousand times. Rate limiting, lockout, expiry, and rotation are the parts of an OTP that only matter under attack, which means they are exactly the parts that are invisible during normal development. They are easy to leave out because leaving them out changes nothing about how the feature behaves for a real user. The code still arrives, still works, still logs the user in. The absence only shows up when someone is guessing.
There is a second reason: an OTP is often bolted on as a side door. The main login goes through SSO or a password with all the protections wired in, and then a “verify with a code” path is added for convenience - onboarding, a phone check, a fallback. That side door frequently inherits none of the front door’s defenses, and it is just as much a way in.
How the attack works
The attack is mechanical, which is the point:
- Find the verify endpoint. Request a code the normal way, then locate the request that submits it.
- Measure the space. Count the digits. Four digits is 10,000 codes; six is a million. Small enough to exhaust either way when nothing throttles.
- Check for a ceiling. Submit wrong codes and watch. Is there a rate limit? A lockout after N failures? A CAPTCHA? Does the code change or expire while you work? If the answer to all of these is no, the endpoint is defenseless.
- Walk the space. Script the guesses. With no throttle and no rotation, one of them is correct, and nothing stops you from reaching it.
The variants are worth naming. Sometimes it is a pure 4-digit OTP as the only auth factor. Sometimes it is a “verify with email and date of birth” gate, where the second value is low-entropy and public. Sometimes it is a resend that hands out a fresh code without invalidating the old, so an attacker collects codes rather than guesses them. The common thread is a small secret with nothing guarding the number of tries.
What it looks like in the wild
We have found this across sectors that share only the shape of the gap:
-
A subscription e-commerce site ran an OTP verify with no throttle, and the code neither rotated nor expired between attempts. The phone-bound code space was small and static, which made it trivially brute-forceable - unlimited guesses against a target that never moved.
-
A diagnostics-AI company gated a patient portal on email plus date of birth, with no rate limit and no CAPTCHA. Both values are low-entropy and frequently discoverable, and nothing stopped an attacker from grinding through candidates.
-
A patient-engagement healthcare platform carried an unthrottled OTP path that functioned as a brute-forceable side door around its SSO. The main login was well-defended; the code path next to it was not, and it led to the same place.
-
A property-management SaaS exposed an unthrottled 4-digit OTP verify - 10,000 possibilities, no lockout, no expiry enforcement. This sat alongside a reset flow with its own separate failure. Full teardown: The Password Reset That Emailed the Attacker.
-
An AI recruiting platform used a 4-digit OTP as the sole authentication factor with no lockout. There was no second wall behind it, so exhausting 10,000 codes was exhausting the entire login.
-
An EV-charging platform had no rate limit on its OTP verify across several hosts, so the same weakness was reachable through more than one door.
The consistent theme: the code was fine; the counting was unguarded. In every case the fix was not a stronger code but a limit on how many times a wrong one could be tried.
How to tell if you are exposed
A short self-check for any OTP or code-verify flow:
- Submit wrong codes in a loop. Send a few hundred deliberately incorrect codes to your own verify endpoint. If none is blocked, throttled, or challenged, you have no rate limit.
- Watch the code over time. Request a code and wait past your intended lifetime. If it still works, expiry is not enforced. Request a second code and check whether the first still works; if it does, you are not invalidating on send.
- Count the digits. A 4-digit code is 10,000 options. Ask whether your other controls could survive an attacker who is allowed to try all of them.
- Find the side doors. Map every path that issues or accepts a code, including resend, onboarding, and fallback flows. A protected main login does not help if an unprotected code path reaches the same account.
How to fix it
- Rate-limit and lock out, per-account and per-IP. Cap attempts on both axes so an attacker cannot spread guesses across accounts or across addresses to dodge a single-key limit.
- Expire codes in minutes. A short, enforced lifetime keeps the attacker racing a clock they cannot win.
- Rotate and invalidate on every failure and every send. A failed guess should burn the code after a small number of tries, and issuing a new code should kill the old one. A code that stands still is a code that gets walked.
- Use sufficient length. At least 6 digits, more where you can. Length is the cheapest way to enlarge the space the attacker has to cover.
- Add step-up after a few failures. A CAPTCHA or a hard cool-down after a handful of wrong codes turns an automated grind into a wall.
A word on the trap this pattern hides. Adding a rate limit feels like the fix, and often it is not the whole one. A throttle does nothing for an OTP that was delivered to the attacker in the first place, and little for one that can be guessed in a single lucky request. Rate limiting is one control in a set - expiry, rotation, length, lockout - not a substitute for the rest.
The takeaway
A one-time code is only as strong as the limit on how many times it can be tried. A small code space with no rate limit, no expiry, and no rotation is not a second factor - it is a countdown an attacker always finishes. Guard the number of attempts, expire and rotate the code, and give it real length.
This pattern recurs across our engagements. One instance, where an unthrottled OTP sat next to a reset flow that leaked its own code, is written up in full: The Password Reset That Emailed the Attacker.
Frequently asked
What makes an OTP brute-forceable?
Why does a code that never expires make brute force worse?
Does adding a rate limit fully fix an OTP problem?
How long should an OTP be and how long should it live?
This is one finding from a harness that runs continuously. See how Greywatch finds, proves, and fixes them.
How it works →