---
title: "Your rate limiter won't stop password guessing — FoxAuth"
description: "A limit per IP address stops request floods, not password guessing. What a sign-in throttle has to count, where to keep the count, and four mistakes I made."
canonical: "https://foxauth.dev/blog/your-rate-limiter-is-not-a-login-throttle/"
lastmod: "2026-10-08T21:45:57+03:00"
---
Blog
Your rate limiter won't stop password guessing
A limit per IP address stops request floods, not password guessing. What a sign-in throttle has to count, where to keep the count, and four mistakes I made.
By FoxAuth · 2026-09-29

The dashboard was flat. Requests per second were normal. The error rate was normal. The rate limiter
(the counter that stops any one IP address from sending too many requests) had not fired once all
night. And one account had a little over two thousand failed sign-ins.

The attacker had done nothing clever. They had a few thousand IP addresses. From each one, they tried
one password against the same email roughly every twenty seconds. Our limit per IP address is tuned
for a browser doing something silly, and every one of those addresses stayed well inside it. The
limiter did exactly what it was built to do: it stopped any single address from flooding the server.
But nobody was flooding the server. They were guessing a password, slowly, from everywhere at once.

For longer than I like to admit, I believed that a rate limiter in front of the sign-in endpoint
protected against brute force (guessing passwords until one works). It doesn’t, and no tuning will
fix that. A rate limiter counts requests per IP address. And the IP address is exactly the thing the
attacker gets to choose.

Count guesses per email address under attack, not per IP address

An attacker chooses the IP addresses. They choose how fast to go, how many machines to use, and when
to stop and start again. The one thing they cannot choose is the account they are attacking: that
account is the whole point. So the counter that limits guessing has to count per account, by the
email address being guessed, not per caller. Every other decision about its design follows from
that one.

The second decision is where to keep the count. Our rate limiter keeps its counters in the server
process’s memory. For its job, that is fine, even though it has three weak spots. If the server runs
as several copies (replicas), each copy has its own counters, so the allowance is multiplied by the
number of copies. A restart wipes the counters. And the table of counters has a size limit: a flood
of ever-changing addresses pushes old counters out, and an address whose counter was pushed out gets
a full allowance again. At that point the limiter quietly lets requests through. This is called
failing open: when the protection breaks, it opens instead of closing.

None of that matters for the rate limiter, because the worst it can lead to is a server that is busier
than it should be. A sign-in throttle is a different thing: a counter of failed sign-ins for one email
address that shuts sign-in for that address for a while. Its worst case is a stolen account. So the
throttle keeps its counter in the database, through the same storage layer as all our other data.
The count is the same on every replica, before and after a restart, however many IP addresses the
guesses come from.

Strictly, the throttle counts by two things together: the bucket and the email address. A bucket is
this server’s name for a group of end users with its own sign-in rules. (Once a bucket has its own
URL, it also issues tokens in its own name, as a separate issuer.)

Both of them run in front of our sign-in endpoint. I want to be clear that the rate limiter is not
wrong. It protects the server’s resources: it keeps the server up. It is just not a sign-in throttle,
which is a security boundary: it keeps the account closed. Once I stopped expecting the limiter to do
the throttle’s job, designing the real throttle came down to a few decisions about one counter stored
in the database. Each decision could go wrong in a way that none of my tests would have caught.

Lockouts have to grow, and the record has to outlive the lockout

Think of sign-in for one email address as a door. The simplest throttle has a cap and a window: after
five wrong passwords, the door shuts for fifteen minutes. Those fifteen minutes are the window.
Against a patient attacker this is nearly useless. A day holds 96 fifteen-minute windows, with five
guesses in each: 480 guesses a day at one account. Worse, the attacker reads your error page. At the
first lockout they learn the schedule, and they set a timer. So the window has to grow. Ours doubles
each time an email address uses up its five attempts: fifteen minutes, then thirty, then an hour, and
never longer than an hour (the ceiling). At the ceiling a day holds 24 windows of five guesses, so a
long attack gets about 120 guesses a day.

NIST SP 800-63B (the US guideline on authentication) draws its line differently: at 100 failed
attempts in a row. That is a different kind of limit. Ours is a rate per day, and it does not limit
the number of days. So a long attack does get past 100 failures in a row, in about a day. We settled on
120 a day because the lockouts also have to be bearable for a real person who mistypes their password.

My first mistake was in this escalation, the growing of the window. I made it by doing the natural
thing. The record that holds the count has a TTL: a lifetime, after which the database deletes the
record. The obvious TTL is the lockout itself: the door reopens, the record is gone, clean slate. Our
throttle on second-factor codes works exactly like that, and there it is right, because that throttle
has nothing to remember between windows. The password throttle does. It has to remember how many
lockouts this address has already had, because that decides how long the next one is. If the record
dies when the door reopens, the throttle forgets, and the next lockout starts at fifteen minutes again.
An attacker who simply waits keeps the shortest window for ever. Nothing reports a problem. Every test
of “the door reopens after the window” passes, because the door does reopen.

So one record has two deadlines, and they mean different things. The window says when the door
reopens, and it is measured in minutes. The expiry, or retention, says when the record is forgotten:
twenty-four hours after the most recent failure. The expiry always has to come after the window ends.
We don’t ask an operator to keep the two numbers in the right order. Instead, the server checks its
configuration at startup and refuses a ceiling longer than the retention.

The retention is counted from the last failure, not the first. So after a quiet day with no failures
the record expires, and the next lockout starts at fifteen minutes again. An honest user who had a bad
Monday is not still on the sixty-minute step on Friday.

Count the attempt before checking the password, or fifteen at once get past a cap of five

The second mistake is the one I am least proud of. It is a textbook race condition: several requests
read the same value at the same moment, and each acts without knowing about the others. I wrote it
anyway. The sign-in code read the counter, checked the password, and on failure wrote the counter
back plus one. Read, modify, write. Send five wrong passwords one after another, and it works
perfectly. Send fifteen at once, and all fifteen read the counter before any of them writes it back.
Every one of them finds the door open, and every password gets checked.

Afterwards the counter may well be high enough to shut the door. That is why a test that checks the
door after the burst passes. A test that counts how many passwords the burst got checked fails. That
is the test we have now.

The fix is to count the attempt before checking the password, and to do it atomically: as one
indivisible operation that no other request can interrupt halfway. Then the count decides whether the
attempt goes ahead at all:

export async function recordAttempt(bucketId: string, email: string): Promise<boolean> {

const key = throttleKey(bucketId, email);

const now = epochTime();

const prior = await held(key);

if (!prior) {

await counters().create(key, { failures: 0, windowStart: now, step: 0 }, RETENTION);

} else if (now - prior.windowStart >= windowFor(prior.step)) {

const exhausted = prior.failures >= cap();

await counters().upsert(

key,

{ failures: 0, windowStart: now, step: exhausted ? prior.step + 1 : prior.step },

RETENTION

);

}

// One atomic $inc, answering this attempt its own number. Fifteen at once get 1..15.

const attempts = (await counters().increment(key, 'failures', RETENTION)) ?? 1;

return attempts <= cap();

}

That increment is a single database operation on both of our databases: a findOneAndUpdate with
$inc on MongoDB, an UPDATE with RETURNING over the jsonb column on PostgreSQL. So fifteen
attempts at the same moment get fifteen different numbers, 1 to 15, and only the first five are
within the cap. The same operation also moves the record’s expiry to twenty-four hours from now. That
is how “kept for twenty-four hours after the last failure” needs no second write.

One thing the function does not show is what keeps the count honest. If the password then turns out
to be correct, the record is cleared. So only failures stay counted. And an attempt that was refused
because the door was shut never gets its password checked at all.

That last point matters. Checking a password with Argon2 (the algorithm we hash passwords with) is
slow on purpose. Before the throttle check moved ahead of the account lookup, every guess at a known
address cost the server a full hash calculation. A throttle placed after the password check limits
the number of guesses but leaves the CPU cost exactly where it was. A throttle placed before it means
that a shut door costs one quick database read by index.

Step 5 (clear the counter) comes before step 6 (is the account inactive, is the email unverified, is a
second factor needed?) on purpose. An account that is deactivated, or whose email is not verified,
still gets refused at step 6. But its counter is cleared first: the password was correct, and nobody
is guessing a password that has already been proved. If the clearing sat at the bottom, next to a
successful sign-in, a user with the right password and an unverified email would never reach it. They
would be throttled for knowing their own password.

Neither the refusal nor the counter may reveal whether an account exists

The box on the right of that diagram, one refusal for every case, is the fix for my third mistake. I
made that mistake by trying to be helpful. The version I wanted to write, and have shipped elsewhere, said “too many attempts, try
again in 14 minutes”. A kind message. It is also an oracle: a response that answers a question the
attacker should not be able to ask. It told the attacker that the address they were probing had an
account (why else count it?). And it told them exactly when to come back.

The fix has two parts, and the second is less obvious than the first. First: every refusal at this
door comes from one expression in the code, which renders one page. So a shut door, an unknown
address, a wrong password and a deactivated account all get the same response, down to the last byte.
The code itself guarantees that: there is one place that produces the page, not four copies of the
same text that someone has to keep identical.

Second: the counter is written for any address submitted, including one that has no account. If the
throttle counted only real accounts, it would behave differently for real and made-up addresses. The
moment that difference shows anywhere (in response time, in a header, through a future bug), the
throttle itself becomes a way to find out which addresses have accounts. That is called an
enumeration attack.

So the storage area that holds these counters deliberately belongs to no account. Its records carry no
account id: a counter for an account that does not exist could not have one. They carry no separate
email field either. The address is already in the record’s key. Repeating it in a field would only
add one more indexed copy of every email anyone has ever typed into the sign-in form.

The same decision applies to events. When the throttle refuses a sign-in, the server emits an event,
and that event names only the bucket, nothing else. An operator needs to know which group of users is
under attack. They do not need a log they could read people’s email addresses out of.

Normalise the key the way the account lookup does, or one address gets 65,536 counters

The fourth mistake passed every test I had, and it would have kept passing. The account lookup
ignores letter case, so alice@example.com and Alice@Example.com are the same user. But the
counter’s key was the address exactly as it was typed. So one address had as many separate counters
as it has ways to mix capital and small letters. Each letter can be either, so an address with
sixteen letters has 65,536 spellings, each with its own cap of five. Every test used a lowercase
address. Every test passed.

/*

* THE RULE IS PARITY WITH THE ACCOUNT LOOKUP, not "lower-case it". findByEmail is

* case-insensitive, so two spellings that resolve to one account must resolve to

* one counter. If the lookup ever starts trimming, folding Unicode or stripping

* +tags, this function is what has to follow it.

*/

export function emailScopedId(bucketId: string, email: string): string {

return `${bucketId}:${email.toLowerCase()}`;

}

The interesting part is the comment, not the code. It would be easy to read it as “remember to
lowercase” and move on. But the rule is different. The account lookup brings different spellings of
an address to one form (this is called normalisation). Whatever normalisation the lookup does, the
key has to do too. Suppose one day someone teaches the lookup to ignore dots in Gmail addresses. From
that day, lowercasing is no longer enough. The comment states the rule as “match the lookup”, not as
one specific change to the text. That way, the next person who changes the lookup knows that this
function has to change with it.

That is also why this is one function, shared by the three storage areas keyed on an email address
(LoginThrottle, PasswordResetThrottle and VerificationResend), and not the same expression written
three times. When it was three copies, one of them had already lost the lowercase call. It did no harm
only because the MongoDB user store, the only one in production then (PostgreSQL support came later),
happens to normalise the addresses it stores.

A completed password reset ends the lockout: the owner can do that, the attacker cannot

Growing lockouts are only bearable if the person who owns the account has a way back in that the
person guessing does not. Otherwise you have built a denial-of-service tool: anyone can point it at
any email address and keep the owner out of their account. At the ceiling, that is an hour of
somebody’s day, and the attacker can repeat it.

The obvious way back is “enter the code we emailed you to keep trying”. I went a fair way down that
road before I wrote down why it is wrong. First, the message is only true for an address that has an
account. Showing it only for those addresses brings the oracle back. Second, it sends an email on
demand to any address anyone types. So anyone could use it to flood someone’s inbox (email bombing).
So it needs its own cap, and then the lockout has just moved there. Third, sign-in would stop working
whenever mail does. Everywhere else on our server, mail is best-effort: we send it if we can. Fourth,
it cannot help the administrators’ own bucket, which has no self-service password reset at all.

What we do instead is narrower and, I think, correct. A completed password reset clears the counter.
A requested one does not. The difference is what each one proves. Asking for a reset proves nothing.
If a request cleared the counter, anyone who knows an address could wipe the count that is holding
them back. To complete a reset, you have to use the single-use secret that was sent to the account’s
email address. That proves you control that mailbox. It is the same proof the “enter the code” step
would have collected. Only here it is collected by a flow that already exists, and that flow already
has its own pause between requests and its own daily cap.

The administrators’ bucket is the reason for the next rule. (It is not the reason the ceiling is one
hour.) Administrators sign in through this same door, and they have no password reset to get out
with. So a long escalation against an administrator’s address is the one lockout nobody can undo. The
rule that protects them is not written as “if this is the admin bucket”. It says: “in a bucket that
requires a second factor, every lockout stays at the first window, however often the throttle trips”.
The reason is that in such a bucket a guessed password alone does not sign anyone in. So long lockouts
there gain little, and they risk a lot: locking the real administrator out. The administrators’ bucket
falls under this rule once its operator turns the second factor on. The first-administrator guide
says to do that first.

Written this way, the rule names the real reason. And it corrects itself: turn a bucket’s second
factor off, and its lockouts start growing again. Nobody has to remember to change anything.

What not to do: an off switch, letting sign-ins through on a database error, and relying on the throttle alone

I would not give the throttle an off switch, and ours has none. That surprised the first person who
looked for one in the settings console. The console has three numbers: the cap, the first window and
the ceiling. Each has limits, checked by the same validator that checks the configuration when the
server starts. So the console cannot save settings that the server would refuse to start with. The
cap cannot go above 100, because a throttle that allows more is no longer a throttle. There is no
on/off checkbox. An off switch on a security boundary is a switch that reopens the hole. And the
console is exactly where switches get flipped at two in the morning and never flipped back.

I would not let the throttle fail open either, that is, let sign-ins through when it breaks. If
reading the counter fails with a storage error, the error is not caught. The counter is read one line
before the account lookup, and the lookup goes to the same database. So if the database is failing,
the sign-in would fail a moment later anyway. And catching the error would mean answering “invalid
username or password” to a user whose sign-in was never refused for that reason.

And I would not pretend the throttle does the whole job. A throttle per email address limits guesses
against one account. It does nothing against password spraying: one common password tried once
against each of ten thousand accounts. Every account sees a single failure, so no counter gets
anywhere near the cap. The rate limiter catches the naive version, sent from one IP address. A botnet
(many machines under the attacker’s control) gets past it exactly the way the attack at the start of
this post got past mine. What actually stops spraying is two things: refusing passwords that appear in
lists of leaked passwords, and requiring a second factor. I have come round to the view that a sign-in
throttle is mostly there to keep you safe enough without a second factor, until you have one.

The defaults in our server are five failures, a fifteen-minute first window and a one-hour ceiling.
The settings reference documents the three
numbers and their limits. The threat model lists what each check at
sign-in is there to stop. Its section on limitations also says plainly which counters in this server
are security boundaries and which only protect resources.

All articlesTry it yourself
