Skip to content
Vulnerabilities

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.

7 min read

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.

  1. Key on the caller. The source address, the device, the API credential, or a combination. Imperfect, and it is at least not attacker-selected.
  2. 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.
  3. Slow the attempt rather than disabling the account. An increasing delay costs an attacker their throughput and costs the real owner almost nothing.
  4. 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.
  5. Never let a failed verification invalidate an unused credential. Burning somebody else one-time code should not be reachable by guessing it wrong.
  6. 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.

  1. Create two accounts you control.
  2. From one session, submit enough failed attempts against the second account to trip the limit.
  3. Try to sign in as the second account, with the correct credentials, from a different browser.
  4. 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.

FAQ

Questions this raises

Is this the same as missing rate limiting?

It is the opposite. Missing rate limiting means an endpoint accepts unlimited attempts. This is a limit that exists, works, and can be aimed at somebody else. Both belong in a report and they need different fixes.

Will a vulnerability scanner find this?

No. A scanner checks whether a limit exists, sees that it does, and records a pass. Seeing the flaw requires two accounts and the question of whose counter was incremented, which is not something a tool can form.

How do we rate limit without being able to lock anyone out?

Key on the caller rather than on a value from the request, pair the account with the caller where the account must be part of the key, and prefer an increasing delay or a challenge over disabling the account. The attempt should get slower; the owner should not lose access.

Does keying on the caller make us weaker against credential stuffing?

Against a distributed attacker with many addresses, somewhat. That is a real trade-off. It is the better one, because the alternative lets a single person with no credentials disable any account they can name.

Can we test for this before booking an assessment?

Yes, and you should. Two accounts, trip the limit against one from the other, then try to sign in normally as the target. If that fails, the limit is keyed on the account. It takes about ten minutes and needs no tooling.