Abuse prevention
Published
A login rate limiter is configuration that exists entirely to be stricter than the library shipping it. Every value in it is a deliberate departure from a general-purpose default, which makes it an unusually clean place to measure what a lost option name costs. We measured it by replaying an attack, not by inspecting the object.
What the sample actually does
The file configures a limiter for one login route: a fifteen-minute window rather than the library's one minute, three attempts rather than five, a key function that buckets by the account being targeted rather than by source address, successful logins excluded from the count, and standard rate-limit headers turned on.
It then replays a credential-stuffing run: one high-value account, one attempt a minute for an hour, a different source address every time. That shape matters. Measuring a limiter by constructing it proves nothing, so every number below comes from counting what actually got through.
The limiter is copied in unprotected, with the documented defaults of the package it models. Protection alone, on five presets, reproduced the unprotected numbers exactly.
The window and the ceiling
Renaming windowMs reverted the window from fifteen minutes to one. Password guesses allowed in the hour moved from twelve to sixty, and guesses blocked moved from forty-eight to zero. Nothing else changed and nothing was logged. The limiter was still installed, still running, and still returning rate-limit headers.
Renaming limit reverted the ceiling from three to five, taking allowed guesses from twelve to twenty. That is a smaller multiplier because the window was still doing most of the work, and it is the arm most likely to be dismissed in review as a rounding difference.
Both are silent, both leave a limiter that passes any test asserting that the eleventh request in a burst is refused, and both change the only number that matters - how many guesses an attacker gets per unit time.
The key function is the one that changes the shape of the attack
Renaming keyGenerator reverted the limiter to bucketing by source address. Allowed guesses went from twelve to sixty, and the number of distinct buckets went from one to sixty: every attempt landed in its own bucket, so the ceiling was never approached. Our example bucket key changed from acct:[email protected] to an IP address.
This is a different kind of loss from the previous two. It does not weaken the limit; it aims the limit at the wrong thing. Per-address limiting catches a single machine spraying many accounts, and is defeated by the exact attack it was replaced to stop - many addresses against one account. The configured limiter and the reverted one both look like working rate limiters in a dashboard.
There is a worse variant. Renaming the key function and the request field its default reads - which one pattern scoped to a request object does easily - produced a bucket key of the literal string undefined. Every request on the service then shares one bucket. Depending on your traffic that is either a global outage or a limiter that has stopped meaning anything, and the only clue is a key name in a store nobody reads.
One option that fails towards your users instead
Renaming skipSuccessfulRequests inverted the accounting. Successful logins in a twenty-second stretch moved from twenty to three: legitimate people signing in normally started consuming the attack budget and getting locked out.
That is the loud direction, and it is the only arm in this article that generates a support ticket. It is also the one most likely to be discovered and fixed quickly, which is precisely why it is the least dangerous.
Renaming standardHeaders removed the rate-limit headers from responses. Nothing about enforcement changed; what changed is that well-behaved clients lost the signal telling them to back off, and any dashboard keyed on those headers went quiet. A missing signal reads as an absence of trouble.
Renaming the answer, not the question
The most complete failure in this area does not touch the configuration at all. The limiter returns a small object saying whether the request is blocked, how many attempts remain, and which bucket it landed in. Renaming those field names left the caller reading undefined.
Undefined is falsy, so the branch that refuses a request never ran. Allowed guesses went to sixty, blocked went to zero, and the reported bucket key became undefined. The limiter itself was configured perfectly, counting correctly, and returning correct verdicts that nothing read.
No error was raised anywhere, because reading a missing property is legal and treating it as false is ordinary JavaScript. This is the strongest argument in the article for testing the outcome rather than the configuration: an assertion on the config object would have found nothing wrong.
What to do about it
Exclude the limiter's option names from your member pattern - windowMs, limit, keyGenerator, skipSuccessfulRequests, skipFailedRequests, standardHeaders and their equivalents - and exclude the field names on the request object your key function reads and on the result object the limiter returns.
Then write the test that all five silent arms fail: replay an attack and assert on the count. Fire more attempts than your ceiling allows, from varying addresses, against one account, and require a specific number to be refused. Run it against the protected build. Every arm here survives a test that checks the limiter is installed.
Finally, note which names carried a non-default value. Renaming a name whose value already equals the library default changes nothing, so the options you deliberately moved away from the default are exactly the ones worth protecting. On a login route that is usually the window, the ceiling and the key function.
Frequently asked questions
Does obfuscation stop my rate limiter working?
Not by itself. Protection alone reproduced our attack-replay numbers exactly on all five presets. The counts changed only when member renaming matched the option names the limiter reads or the field names it returns.
How much weaker did the limiter actually get?
In our replay, renaming the window took allowed password guesses in one hour from twelve to sixty and blocked attempts from forty-eight to zero. Renaming the ceiling took twelve to twenty. Renaming the key function also gave sixty, by spreading every attempt into its own bucket.
Why is renaming the key function worse than renaming the limit?
Because it aims the limiter at the wrong thing rather than loosening it. Reverting to per-address bucketing catches one machine attacking many accounts and misses many machines attacking one account, which is the attack the account-based key was chosen to stop.
What happens if the key function and the field it falls back on are both renamed?
Every request shares a single bucket named after an undefined value. That is either a service-wide lockout or a limiter that no longer distinguishes anyone, and the only visible sign is the bucket key itself.
Does anything fail loudly?
One thing. Renaming the option that excludes successful logins from the count made ordinary users consume the attack budget, so real people were locked out after three sign-ins. That produces support tickets, which is why it is the least dangerous result here.
Can the limiter stop blocking without any error?
Yes, and it does not require touching the configuration. Renaming the fields on the object the limiter returns leaves the caller reading undefined, which is falsy, so the refusal branch never runs. The limiter counts correctly and nothing reads the answer.
What test would have caught all of this?
An attack replay that asserts on a count: more attempts than the ceiling, from varying addresses, against one account, with a required number refused. Run it against the protected build rather than the source.
Related reading