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});What is throttled by default
Section titled “What is throttled by default”@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:
| Endpoint | Default protection |
|---|---|
| Every route, including auth | The global http.rateLimit (60 requests per minute per IP since 5.31). |
POST /auth/login in a new create-warlock app | loginThrottleMiddleware(): 5 failures in 15 minutes lock the account and the IP for 15 minutes. |
POST /auth/forgot-password in a new app | tokenIssueThrottleMiddleware(): 3 requests per hour per email and per IP. |
POST /auth/reset-password in a new app | tokenConsumeThrottleMiddleware(): 10 failures per 15 minutes per IP. |
POST /auth/refresh-token in a new app | A route rate limit of 30 requests per minute. |
| OTP request/verify | otpRequestThrottleMiddleware() / 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:
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()], });});Options
Section titled “Options”| Option | Default | Purpose |
|---|---|---|
max | 5 | Failures 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 |
errorMessage | i18n auth.errors.tooManyAttempts | 429 message override |
isFailure | (res) => !res.isOk | What counts as a failed attempt |
identify | built-in email + ip extraction | Custom identifier list |
How failures are counted
Section titled “How failures are counted”After the controller responds, a non-2xx response counts as a failure for each tracked identifier:
- 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.
- The counter is bumped with an atomic
cache.increment. - When the count reaches
max, a lock key is written forlockoutDuration.
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.
Keys never carry the credential
Section titled “Keys never carry the credential”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.
Fails open
Section titled “Fails open”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.
Related
Section titled “Related”- Handle login and logout — the flow whose failures this counts.
- Protect routes — stack the throttle in front of
authMiddlewarewhere needed. - Verify email and reset password — presets that wrap
loginThrottleMiddleware.
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.