Skip to content

Login Throttles That Survive a Restart

8 min read · updated August 4, 2026

A rate limiter built on a Map in module scope bounds password guessing only until the next deploy. It is emptied by every restart and every crash, and it is enforced per process, so two workers enforce it twice over. The durable version is two columns and one SQL statement, and the test that matters is the one asserting the counter is still there after the process is not.

What an in-memory counter actually bounds

The usual implementation is a token bucket or a sliding window kept in a module-level map, keyed by email address or IP. It is fast, it needs no infrastructure, and for most endpoints it is the right tool. For a credential check it has three properties that an attacker can use directly.

  • It is cleared by a restart. Every deploy, every crash, every autoscaler decision, every systemctl restart zeroes it. An attacker does not need to cause a restart — they only need to outlast a normal deployment week, and most teams deploy more often than a serious lockout window is long.
  • It is per process. Two replicas behind a load balancer each hold their own map, so a limit of eight is a limit of sixteen. Ten replicas is eighty, and the attacker does not have to do anything to spread their attempts — the load balancer does it for them.
  • It is invisible when it fails. There is no log line for “the counter that was protecting this account was reset”, because from the process’s point of view nothing happened.

A real audit of one gateway codebase found exactly this: the only bound on grinding one known address was a map, while the second-factor step sitting behind it already had a durable counter in the database — with a comment explaining precisely why an in-memory limiter is not enough for a credential check. The reasoning was correct and had simply never been carried across to the password step in front of it. That is the ordinary way this bug exists: not as an oversight about security, but as a good argument that stopped one function short.

Two layers, and what each one catches

The durable counter replaces nothing. Both layers stay, because they catch different attacks and neither can see the other’s.

LayerDescription
in-process, per IPCatches credential stuffing: one password sprayed across thousands of accounts. A per-account counter cannot see this at all, because no single account is attacked more than once. Cheap, answers before any query runs, and its volatility matters less because the attack it stops is high-volume and short.
in-process, per addressThe cheap first line for a single-victim attack. Answers before the database is touched, which keeps the load off. Reset by every restart, which is why it cannot be the only one.
durable, per accountA column on the user row. Survives restarts, is shared across replicas by construction, and is the only one of the three that bounds a patient attacker grinding one known address over weeks.

A shared store — Redis, or the database — would make the per-IP layer durable too, and that is worth doing at scale. It is a different job from this one: per-IP state is high-cardinality and disposable, while a per-account lock is low-cardinality and belongs next to the account it locks. The general shape of the first is covered in rate limiting as a security control.

The migration

Two columns on the users table. The count is the run of consecutive failures; the timestamp is when the current lock expires, null when there is none.

ALTER TABLE users ADD failed_login_count INTEGER DEFAULT 0 NOT NULL;
ALTER TABLE users ADD locked_until INTEGER;

Storing the lock as an expiry rather than a boolean is what removes the need for a background job to clear it. Nothing has to unlock anything; the lock simply stops being true.

export function isLocked(lockedUntil) {
  return Boolean(lockedUntil && lockedUntil > Math.floor(Date.now() / 1000));
}

Seconds rather than milliseconds, and an integer rather than a date string, so that comparisons in SQL and in application code cannot disagree about a format. This is the same reasoning that makes idempotency keys boring to store: the representation should have exactly one obvious comparison.

One statement, never read-then-write

This is the part that is easy to get subtly wrong, and the failure mode is specific: reading the count into application code and writing back count + 1 loses increments whenever two attempts land together. Both read 3, both write 4, and two failures cost one. That is not a rare race — parallel attempts are the shape an attacker produces by definition, so the lost-update case is the normal case under attack and the never-happens case in testing.

Put the arithmetic in the statement, including the lock calculation, so the database serialises it:

UPDATE users
SET failed_login_count = failed_login_count + 1,
    locked_until = CASE
      WHEN (failed_login_count + 1) >= 5
      THEN :now + min(
             3600,                                        -- the ceiling
             900 * (1 << (((failed_login_count + 1) / 5) - 1))
           )
      ELSE locked_until                                   -- leave an existing lock alone
    END
WHERE id = :userId;

The shift is the backoff. Integer division truncates, so (n / 5) - 1 is 0 for the fifth failure through the ninth, 1 for the tenth through the fourteenth, and so on — which makes 1 << that a doubling at every multiple of five. The min() applies the ceiling. One expression, no branches in application code, and no window in which the count has moved but the lock has not.

Two smaller decisions in the same function. It should be safe to call with a null user id, so the caller can invoke it on every failure without branching on whether the address exists — keeping the call shape identical is part of what stops the function becoming a timing oracle. And a successful sign-in should clear the run in the same statement that stamps the last-login time, rather than in a second query, so a partial failure cannot leave a stale lock on an account whose owner just proved themselves.

The backoff curve, and why it caps

Any account lockout is a denial of service against the real owner. Anyone who knows an address can hold it shut by failing on purpose, forever, for free. That is not an argument against locking; it is the constraint that decides the numbers.

Consecutive failuresDescription
0–4No lock at all. A typo is not an attack, and a customer who mistypes twice should never meet this system.
5–915 minutes.
10–1430 minutes.
15 and aboveOne hour, and it stops there.

The cap is the part that gets edited by somebody who thinks harder is better, so it is worth deriving. With a one-hour ceiling and a five-failure threshold, an attacker’s sustained rate is five attempts per hour — 43,800 a year against a password with a twelve-character minimum. That is not a threat to any password a policy would accept. Doubling the ceiling to two hours halves a number that is already irrelevant, and doubles the worst case for a customer locked out by somebody else from “wait an hour” to “wait until after lunch”. A day-long lock buys nothing measurable and costs a working day.

The other reason to cap is that the lock is the attacker’s tool as much as yours. An uncapped exponential means the attacker can escalate a victim’s lockout to arbitrary length with a handful of requests, which turns a defence into a weapon.

A lockout is not the only answer available. A step-up challenge — a CAPTCHA, an emailed confirmation, or a second factor demanded after the fifth failure — bounds guessing without ever denying the real owner access. It is strictly better and strictly more work, and it needs a delivery channel that is itself reliable. The durable lock is the version that is two columns.

Not building an enumeration oracle

Adding a per-account lock introduces a new way to answer “does this address have an account here”, and it does so out of two pieces that are individually fine. The in-process bucket can be tripped for any address, real or not. The durable lock can only exist for a real account. If the two reply differently, an attacker gets a clean oracle for free.

Two rules keep that closed.

  1. Both throttles answer with the identical sentence. One constant, referenced twice. An honest “this account is temporarily locked” is a confirmation that the account exists, and reusing the generic message costs the reader nothing they could not learn by other means.
  2. Check the lock after the password hash is verified, not before. The obvious ordering — refuse early, skip the expensive hash — makes a locked account answer measurably faster than an unknown address, and the timing difference is the same oracle in another channel. Always burn one hash verification, against a dummy hash where no user exists, and consult the lock afterwards.
// Always one scrypt, even with no user and no stored hash.
const ok = await verifyPassword(password, user?.passwordHash ?? DUMMY_HASH);

// Durable half, checked AFTER the hash so a locked account and an
// unknown address cost the same wall-clock time.
if (isLocked(user?.lockedUntil)) return { error: TOO_MANY_ATTEMPTS };

if (!user || !user.passwordHash || !ok) {
  await noteFailedLogin(user?.id);   // no-op for a null id
  return { error: BAD_CREDENTIALS };
}

The cost of that ordering is one scrypt verification on every locked attempt, which is the point — the work is what makes the timings match. It also means the lock does not save you CPU under attack, so the per-IP bucket in front of it is still doing real work.

The test that survives a restart

“Does the counter survive a restart” sounds like it needs a process manager. It does not, and the version that does not is better, because it also catches the drift that a live test would miss.

  1. Write the curve twice and pin them together. The curve lives in SQL, where it has to be atomic. Export a JavaScript twin for testing and assert the values. Two expressions of one rule is exactly the shape that drifts, so the test’s job is to make drift fail the build.
  2. Assert the SQL is still SQL. Read the source file and assert that the increment is failed_login_count + 1 inside the statement and not a bound parameter. This is a grep assertion, it is unglamorous, and it is the only thing that catches somebody “simplifying” the atomic update into a read and a write.
  3. Prove durability against a real database file. Open the database, record five failures, close the connection and reopen it, and assert the account is still locked. That is the restart, and it is the assertion the in-memory implementation cannot pass.
  4. Assert the cap and the two throttles’ shared message. Both are one edited constant away from being wrong.
import assert from "node:assert/strict";
import { lockSecondsFor, MAX_LOCK_SECONDS, FAILURES_BEFORE_LOCK } from "../lib/lockout.ts";

/* --- the curve -------------------------------------------------------- */
for (const n of [0, 1, 2, 3, 4]) {
  assert.equal(lockSecondsFor(n), 0, n + " failures must not lock");
}
assert.equal(lockSecondsFor(5),  900);   // 15 minutes
assert.equal(lockSecondsFor(9),  900);   // still, until the next multiple
assert.equal(lockSecondsFor(10), 1800);
assert.equal(lockSecondsFor(15), 3600);

/* --- the cap, which is the whole reason the DoS is survivable --------- */
for (const n of [20, 50, 500, 10_000]) {
  assert.equal(lockSecondsFor(n), MAX_LOCK_SECONDS);
}
assert.equal(MAX_LOCK_SECONDS, 3600, "the cap is an hour, not a day");

/* --- the sustained rate the cap implies ------------------------------- */
assert.equal(
  Math.round((FAILURES_BEFORE_LOCK * 3600) / MAX_LOCK_SECONDS),
  5,
  "5 attempts an hour, not 5 per deploy",
);

/* --- the increment is still atomic ------------------------------------ */
const source = readFileSync("lib/lockout.ts", "utf8");
assert.match(source, /failed_login_count \+ 1/, "read-then-write has crept back in");
assert.match(source, /min\(/,                   "the ceiling left the SQL");

And the durability assertion, which is the one the page is named for:

let db = openDatabase("./test.db");
for (let i = 0; i < 5; i++) await noteFailedLogin(db, USER_ID);
assert.ok(isLocked(await lockedUntilFor(db, USER_ID)));

db.close();               // the restart
db = openDatabase("./test.db");

assert.ok(
  isLocked(await lockedUntilFor(db, USER_ID)),
  "the lock did not survive the process that set it",
);

Run it against a real database file rather than an in-memory one. An in-memory database is itself cleared by the close, so the test would pass on an implementation with the bug — which is a good reminder that a durability test run against volatile storage tests nothing at all.

What this does not solve

It does not stop credential stuffing. A per-account counter never sees an attack that tries one password against a hundred thousand accounts. That is the per-IP layer’s job, and beyond a certain volume it is a detection problem rather than a limiting one.

It does not stop an attacker with the password. A throttle bounds guessing. Against a credential from a breach, the first attempt succeeds and nothing here fires. Second factors and breached- password checks are the controls for that, and they are complementary rather than alternative.

It does not cover every door. A durable lock on the sign-in form and nothing on the password-change form, the second-factor step, the API-key exchange or the account-recovery flow is a lock on one of five doors. Every path that verifies a secret should count against the same counter — which, incidentally, is a good argument for the counter being on the user row rather than inside the sign-in module. Where a federated sign-in exists, note that it bypasses the password path entirely, and the questions it raises are different ones: see account pre-hijacking.

It does not tell anyone. A lock is a signal that somebody is being attacked. Recording the event where a human or a sweep can see it — and telling the account owner that failures happened — turns the throttle from a wall into an alarm, at the cost of one more email template. That record belongs with the rest of the account’s audit trail, not in a log file nobody reads.