Skip to content

Throttle login attempts

loginThrottleMiddleware() defends login, refresh and password-reset routes against brute-force and credential stuffing. It counts only failed attempts, clears the counter when a login succeeds, and rejects a locked request with 429 (AuthErrorCodes.TooManyAttempts, EC004) before the controller — and the bcrypt verify — ever runs.

import { loginThrottleMiddleware } from "@warlock.js/auth";
router.post("/auth/login", loginController, {
middleware: [loginThrottleMiddleware()], // 5 failures / 15m → 15m lockout, per email + ip
});
OptionDefaultPurpose
max5Failures within the window before lockout
window"15m"Counting window (ms-string or seconds)
lockoutDuration"15m"How long the lock lasts once tripped
by["email", "ip"]Identifiers tracked, each independently
identifierKey"email"Credential field used as the account key
errorMessagei18n auth.errors.tooManyAttempts429 message override
isFailure(res) => !res.isOkWhat counts as a failed attempt
identifybuilt-in email + ip extractionCustom identifier list

After the controller responds, a non-2xx response counts as a failure for each tracked identifier:

  1. The first failure opens the window with a create-only cache write that carries the TTL. An existing window keeps its deadline, so the window is fixed, not sliding.
  2. The counter is bumped with an atomic cache.increment.
  3. When the count reaches max, a lock key is written for lockoutDuration.

Because the bump is atomic, concurrent failures on different servers are never lost: on the redis and pg cache drivers the counts are exact across every instance. A 2xx response clears the counter and the lock.

Storage is @warlock.js/cache. If the cache driver is unavailable the middleware logs the error and lets the request through — a throttle outage must never become an auth outage. That also means no cache, no protection.