When rate limiting locks out your users
Missing rate limiting is a well known finding. The inverse is not: a limit that has been implemented, works exactly as designed, and can be used to deny service to any account in your system. It comes from one decision, made early, that almost nobody revisits.
Key takeaways
- A rate limit is only a defence if it counts something the attacker cannot choose. Key it on a value from the request and it becomes a weapon.
- The classic case is a limit keyed on an account identifier submitted in the body: an attacker sends failed attempts for somebody else and locks them out.
- Nothing about this looks like an attack in your logs. The requests are well formed and the control is working as written.
- No scanner reports it, because the endpoint behaves correctly. Finding it requires asking what the counter is counting.
- The fix is usually to key on the caller and to fail closed on the attempt rather than on the account.
The shape of it
You build a login endpoint. To stop credential stuffing you add a limit: five failed attempts and the account is locked for fifteen minutes. Sensible, and it appears in every hardening guide.
The question that decides whether this is a defence or a liability is what the counter is keyed on. If it counts failures against the account named in the request, then anybody who knows an account identifier can lock that account, and they never need a password to do it. Five requests and the real owner cannot sign in.
Scale that across a user list and you have a denial of service against the whole platform, delivered through a control that was added to prevent one.
Why this survives review
Everything about the implementation is correct. The limit works, the lockout expires, the logic matches the specification, and a code reviewer reading the handler sees a control doing exactly what it says. The flaw is in what the key represents, which is a design question that code review is not shaped to ask.
Where it turns up
Anywhere a limit is keyed on a value that arrives in the request rather than on the party making it. Identifiers are the usual culprit because they are convenient and they look like the natural thing to count.
- Login, keyed on the username, email address or phone number in the body.
- One-time codes, where too many verification attempts invalidate the code and the attacker can burn somebody else code before it is used.
- Password reset, where requesting resets on behalf of a target both floods their inbox and can invalidate a token they are mid-way through using.
- Any endpoint keyed on a tenant or organisation identifier, where one customer can exhaust the allowance of another.
- Promotional or referral limits keyed on a code rather than on the redeemer.
The OWASP API Security Top 10 covers the missing half of this as unrestricted resource consumption. The inverse case sits between that and business logic, which is part of why it is written about so rarely: it does not fit neatly in either box.
Why tools do not report it
An automated scan checks whether a limit exists. It sends requests until it is refused, records that the endpoint is protected, and moves on. From its point of view a lockout is a passing result.
To see the problem you have to ask a question a tool has no way to form: whose counter did I just increment? That takes two accounts and somebody willing to check whether the second one still works after the first has been throttled. It is a small test and it is not an automatable one, which is the whole argument for testing by hand in one example.
Your own monitoring is equally quiet. There is no malformed input, no unusual volume from any single source if the attacker paces it, and no error to alert on. The logs record a series of failed logins, which is what a login endpoint records all day.
How to key it instead
The rule is short: count something the attacker cannot choose, and make the consequence fall on the attempt rather than on the account.
- Key on the caller. The source address, the device, the API credential, or a combination. Imperfect, and it is at least not attacker-selected.
- Where the account has to be part of the key, pair it with the caller so a single source cannot lock an account on its own.
- Slow the attempt rather than disabling the account. An increasing delay costs an attacker their throughput and costs the real owner almost nothing.
- Use a challenge instead of a lockout. Presenting a proof-of-work or a second factor after repeated failures stops automation without denying anybody access.
- Never let a failed verification invalidate an unused credential. Burning somebody else one-time code should not be reachable by guessing it wrong.
- Alert on the pattern, not the threshold: many different accounts throttled from few sources is the signature worth waking somebody for.
The trade-off is real, and it is the right one
Keying on the caller is weaker against a distributed attacker with many addresses. That is a genuine cost. It is smaller than the cost of shipping a control that lets one person disable any account they can name, because the second failure mode needs no infrastructure and no credentials.
Testing for it yourself
This is one of the few application security tests a developer can run in ten minutes without tooling, and it is worth doing before anybody bills you for it.
- Create two accounts you control.
- From one session, submit enough failed attempts against the second account to trip the limit.
- Try to sign in as the second account, with the correct credentials, from a different browser.
- If it is refused, the limit is keyed on the account and anybody can do this to anybody.
Repeat it for password reset, one-time code verification and any endpoint with a per-tenant allowance. The same question applies everywhere: whose counter did that increment.