All Docs
FeaturesMaking Tax DigitalUpdated March 11, 2026

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 controlScopeCovers /api/auth/?
tRPC rate limiter/api/trpc/*❌ No
Upstash Redis distributed limiterManually 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

AttackMitigation
Password brute-force against a known accountLocked 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

PropertyDetail
Vulnerability classInsecure Design (OWASP A04)
Control referenceSEC-08
Releasev1.0.405
Enforcement layersRedis middleware + database authorize() callback
Lockout threshold5 consecutive failures
Tracking keylogin:fail:{emailHash} (Upstash Redis)
Reset mechanismRedis TTL (automatic expiry)