An unprotected login endpoint allows unlimited automated password guessing. CWE-307 (Improper Restriction of Excessive Authentication Attempts) is one of the most exploited authentication failures: a botnet can attempt millions of credential combinations against any account in minutes. NIST 800-63B §5.2.2 and PCI-DSS Req-8.3.4 both mandate throttling on authentication endpoints. OWASP A07 lists brute-force susceptibility as a named failure mode. In-memory rate limiting does not survive serverless cold starts or multi-instance deployments — Redis-backed state is required for distributed environments.
Critical because an unthrottled login endpoint allows automated brute-force attacks that can compromise any account given sufficient time and a common password list.
Use a Redis-backed rate limiter that persists state across serverless instances. Limit by IP and by username to catch both distributed and targeted attacks:
// Using @upstash/ratelimit (serverless-safe)
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '15 m'),
prefix: 'login'
})
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) return Response.json({ error: 'Too many attempts' }, { status: 429 })
// login logic
}
Apply the same rate limit to password reset and OTP verification endpoints — they are equally vulnerable to abuse and are frequently overlooked.
ID: saas-authentication.auth-flow.rate-limit-login
Severity: critical
What to look for: Examine the login API route (typically POST /api/auth/signin, /api/auth/login, or the NextAuth signin callback). Check for rate limiting middleware applied to this route — look for @upstash/ratelimit, express-rate-limit, rate-limiter-flexible, custom Redis-based rate limiting, or Vercel Edge Config rate limiting. Verify the limit is applied per IP address or per username, not per application instance only (in-memory rate limiting without Redis doesn't survive across serverless function invocations). Count every authentication endpoint (login, signup, password reset, OTP verification) and enumerate which have rate limiting configured vs. which are unprotected.
Pass criteria: The login endpoint has rate limiting that persists across requests — either via a Redis-backed rate limiter, a managed provider's built-in rate limiting (Clerk, Auth0), or a hosting platform feature (Vercel Edge Middleware with distributed state). The limit should be restrictive enough to prevent brute-force attacks (e.g., 10-20 attempts per IP per 15 minutes). Report even on pass: "X of Y auth endpoints have rate limiting. Thresholds confirmed on login, signup, and reset endpoints." At least 1 implementation must be confirmed.
Fail criteria: No rate limiting on the login endpoint, or rate limiting is only in-memory (which doesn't persist across serverless cold starts or multiple instances), or rate limiting is per-application rather than per-IP/per-user. Do NOT pass if rate limiting exists on the login endpoint but not on the password reset endpoint — password reset is equally vulnerable to abuse.
Skip (N/A) when: Fully managed auth provider (Clerk, Auth0, Firebase Auth) is used with default configuration and no custom login endpoint — rate limiting is handled by the provider. Signal: no custom login route, provider handles all auth flows.
Detail on fail: "Login route at /api/auth/login has no rate limiting — unlimited brute-force attempts possible" or "Rate limiting uses in-memory counter only (no Redis) — not effective for serverless deployments with multiple instances".
Remediation: Without rate limiting, an attacker can attempt millions of password guesses against any account. Even a simple rate limit dramatically raises the bar:
// Using @upstash/ratelimit with Redis (works in serverless)
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, '15 m'), // 10 attempts per 15 min
prefix: 'login'
})
export async function POST(request: Request) {
const ip = request.headers.get('x-forwarded-for') ?? 'unknown'
const { success } = await ratelimit.limit(ip)
if (!success) return Response.json({ error: 'Too many attempts' }, { status: 429 })
// ... proceed with login logic
}
Rate limit by IP AND by username to prevent distributed attacks. For a deeper analysis of rate limiting patterns across your API, the SaaS Readiness Pack Authorization Audit covers this in detail.