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
});

@warlock.js/auth does not register routes, so its throttles apply where you attach them. What protects an app’s auth endpoints out of the box:

EndpointDefault protection
Every route, including authThe global http.rateLimit (60 requests per minute per IP since 5.31).
POST /auth/login in a new create-warlock apploginThrottleMiddleware(): 5 failures in 15 minutes lock the account and the IP for 15 minutes.
POST /auth/forgot-password in a new apptokenIssueThrottleMiddleware(): 3 requests per hour per email and per IP.
POST /auth/reset-password in a new apptokenConsumeThrottleMiddleware(): 10 failures per 15 minutes per IP.
POST /auth/refresh-token in a new appA route rate limit of 30 requests per minute.
OTP request/verifyotpRequestThrottleMiddleware() / otpVerifyThrottleMiddleware() when you attach them.

An app created before 5.31 keeps its own routes.ts; add the same middleware to its sign-in and recovery routes:

src/app/auth/routes.ts
import {
loginThrottleMiddleware,
tokenConsumeThrottleMiddleware,
tokenIssueThrottleMiddleware,
} from "@warlock.js/auth";
import { router } from "@warlock.js/core";
import { forgotPasswordController } from "./controllers/forgot-password.controller";
import { loginController } from "./controllers/login.controller";
import { resetPasswordController } from "./controllers/reset-password.controller";
router.prefix("/auth", () => {
router.post("/login", loginController, {
public: true,
middleware: [loginThrottleMiddleware()],
});
router.post("/forgot-password", forgotPasswordController, {
public: true,
middleware: [tokenIssueThrottleMiddleware()],
});
router.post("/reset-password", resetPasswordController, {
public: true,
middleware: [tokenConsumeThrottleMiddleware()],
});
});
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.

The account and IP keys are keyed hashes (auth.throttle.count.email.<hmac>, auth.throttle.count.ip.<hmac>), not the address or IP itself: cache drivers log the keys they touch, and an email or IP in a key would land in the log. The HMAC key is derived from auth.accessToken.secret, so someone holding a list of addresses cannot reverse a key (without a configured secret the throttle falls back to a plain SHA-256). The value is trimmed and lower-cased first, so " Sara@x.io" and "sara@x.io" share one bucket. Changing the secret resets every counter and lock once.

The lock stores its own deadline, so it always lasts lockoutDuration, even on a cache driver with sliding expiration (memoryExtended). The null cache driver stores nothing, so the throttle can never lock on it; Warlock warns once when that happens.

A custom identify returns its keys as they are; hash anything personal yourself.

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.

The failure counter passes the configured window TTL to every atomic increment. If a key expires after its create-only seed and before the increment, the replacement counter still expires. Existing windows keep their deadlines; this works even when the Redis driver has no default TTL.

Framework contributors can verify the throttle against real Redis from packages/auth with WARLOCK_REDIS_INTEGRATION=1 npx vitest run src/middleware/login-throttle.redis.spec.ts. The suite uses QUEUE_REDIS_HOST / QUEUE_REDIS_PORT (default 127.0.0.1:6390) and UUID-prefixed keys; it never flushes the database. It covers shared failures and lockouts, concurrent counts, success resets, lock expiry and a seed expiring before increment. Local runs without the flag clearly skip; an opted-in run fails when Redis is unavailable, and CI always opts in.