Security Fix: Rate Limiting & Account Lockout on the Sign-In Endpoint (SEC-08)
Security Fix: Rate Limiting & Account Lockout on the Sign-In Endpoint (SEC-08)
Release: v1.0.405
OWASP Category: A04 — Insecure Design
Severity: High
Affected route:POST /api/auth/callback/credentials
Background
Our platform uses Auth.js (formerly NextAuth.js) to handle user authentication. The credentials provider exposes a POST /api/auth/callback/credentials endpoint that accepts an email address and password.
Prior to this release, that endpoint had no rate limiting or account-lockout logic. An attacker who knew (or could enumerate) a valid email address could submit an unlimited stream of password guesses without any throttling, delay, or lock-out — a classic brute-force / credential-stuffing attack vector.
Why existing rate limiters did not help
| Existing control | Scope | Covers /api/auth/? |
|---|---|---|
| tRPC rate limiter | /api/trpc/* | ❌ No |
| Upstash Redis distributed limiter | Manually applied to route handlers | ❌ Not wired up |
Neither control intercepted Auth.js internal route handlers, leaving the sign-in endpoint completely unprotected.
What changed in v1.0.405
1. Rate-limiting middleware on the Auth.js handler
The Auth.js handler in src/app/api/auth/[...nextauth]/route.ts is now wrapped in a middleware layer that intercepts every credentials sign-in attempt before the password is checked.
POST /api/auth/callback/credentials
└─ Rate-limit middleware (Upstash Redis)
└─ Auth.js Credentials authorize()
└─ users table lookup + attempt counter check
2. Per-email tracking in Upstash Redis
Failed attempts are counted against a Redis key scoped to the hashed email address:
login:fail:{emailHash}
Using the email hash (rather than the source IP address) prevents attackers from bypassing the limit by rotating IP addresses or using proxies/VPNs.
3. Progressive lockout after 5 failed attempts
- Attempts 1–4: the request is processed normally.
- Attempt 5+: the middleware returns an error response immediately, without forwarding the request to Auth.js. The lockout window is time-bounded and resets automatically via Redis TTL.
4. Persistent attempt counter in the database
The Credentials authorize() callback now reads a failedLoginAttempts counter from the users table. If the counter exceeds the configured threshold and the lockout window has not yet expired, the callback rejects the login attempt — providing a secondary enforcement layer that is durable across server restarts and Redis evictions.
Attack scenarios mitigated
| Attack | Mitigation |
|---|---|
| Password brute-force against a known account | Locked out after 5 failures; Redis TTL controls reset |
| Credential stuffing (email/password list replay) | Per-email hashed key; IP rotation has no effect |
| Distributed low-and-slow guessing (many IPs) | Per-email tracking is IP-agnostic |
Recommended additional control: MFA
Rate limiting reduces the probability of a successful brute-force attack. To reduce its impact even if an attacker eventually guesses a password (e.g. via a very long, slow campaign), we recommend enabling Multi-Factor Authentication (MFA) for all accounts. Even a correct password becomes insufficient to log in without the second factor.
See the Authentication guide for MFA setup instructions.
Summary
| Property | Detail |
|---|---|
| Vulnerability class | Insecure Design (OWASP A04) |
| Control reference | SEC-08 |
| Release | v1.0.405 |
| Enforcement layers | Redis middleware + database authorize() callback |
| Lockout threshold | 5 consecutive failures |
| Tracking key | login:fail:{emailHash} (Upstash Redis) |
| Reset mechanism | Redis TTL (automatic expiry) |