# Login / signup / password-reset endpoints have rate limits

- **Pattern:** `ab-002604` (`project-snapshot.abuse.rate-limit-on-auth-endpoints`)
- **Severity:** high
- **Lifecycle:** active
- **Last modified:** 2026-04-25
- **Canonical URL:** https://auditbuffet.com/patterns/ab-002604
- **License:** CC-BY-4.0 — attribute to AuditBuffet Pattern Catalog (https://auditbuffet.com/patterns/ab-002604)

## Why it matters

Authentication endpoints without rate limits are a credential-stuffing accelerant: attackers replay leaked username/password pairs from prior breaches against the `/login` endpoint at ten thousand requests per second from a distributed botnet, and any account whose user reuses a password is compromised. 23andMe's October 2023 breach — 6.9 million users exposed, followed by an FTC settlement and class-action payouts — was a pure credential-stuffing attack that succeeded specifically because login attempts were not rate-limited. Signup endpoints are equally risky: unrate-limited signup enables automated account-creation to harvest free trials, spam the platform, or warm up accounts for later abuse. Password-reset endpoints without rate limits enable enumeration (probing whether an email exists by reading response timing) and SMS/email-cost pumping. AI coding tools omit rate limits on auth endpoints almost universally, because the happy-path flow works fine with a single request.

## Severity rationale

High because credential-stuffing at scale produces real account takeovers and direct regulatory liability (FTC, state AG actions, GDPR breach reporting), and the remediation is cheap while the exposure compounds over time as breach corpora grow.

## Remediation

Use `@upstash/ratelimit` with Vercel KV (or Redis) to gate login, signup, and password-reset by IP + email:
  ```ts
  import { Ratelimit } from '@upstash/ratelimit';
  import { kv } from '@vercel/kv';
  const limiter = new Ratelimit({ redis: kv, limiter: Ratelimit.slidingWindow(5, '1 m') });
  const { success } = await limiter.limit(`login:${ip}:${email}`);
  if (!success) return new Response('Too many attempts', { status: 429 });
  ```
  For Supabase Auth, enable the built-in rate limits in `supabase/config.toml` under `[auth.rate_limit]`. Run `api-security` for deeper coverage of enumeration-resistance, CAPTCHA escalation, and MFA.

## Detection

- **ID:** `rate-limit-on-auth-endpoints`
- **Severity:** `high`
- **What to look for:** Enumerate handlers in the three required categories. **login**: `/api/auth/login|signin`, `/api/login|signin`, NextAuth `pages/api/auth/[...nextauth]` POST, Supabase `signInWithPassword|signInWithOAuth|signInWithOtp` call sites, Clerk `signIn()`, plus `'use server'` actions named `login|signIn|authenticate|loginAction`. **signup**: `/api/auth/register|signup`, `/api/register|signup`, Supabase `signUp`, Clerk `signUp`, plus equivalent server actions. **password-reset**: `/api/auth/reset-password|forgot-password|verify-password`, `/api/reset-password`, Supabase `resetPasswordForEmail` / `updateUser({ password })`, Clerk `createPasswordResetFlow`, plus server actions. For each, verify one of: (a) a `ratelimit.limit(...)` / `@upstash/ratelimit` / `rate-limiter-flexible` / `express-rate-limit` / project-local `rateLimit(key, limit, window)` call before the auth attempt; (b) Supabase built-in limits: `supabase/config.toml` under `[auth.rate_limit]` with non-trivial values; (c) Vercel Firewall rules in `vercel.json` under `firewall.rules` targeting the auth paths.
- **Pass criteria:** ALL THREE of login AND signup AND password-reset are rate-limited before the auth attempt. Key combines IP + identifier (email/username). Threshold is auth-appropriate (~5/min, not 100/min).
- **Fail criteria:** Any of the three runs its auth call without a limiter. Partial coverage fails — login gated but signup/reset open. A limiter imported but never called fails. A generic `/api/*` middleware at 100/min does NOT count unless you trace its matcher and confirm it covers all three paths AND its threshold is auth-appropriate. `// TODO: add rate limit` does not count.
- **Skip (N/A) when:** No auth system. Quote: `"package.json lacks @supabase/*, next-auth, @clerk/*, @auth0/*; no /api/auth/* handlers; no server actions named login / signup / resetPassword"`.
- **Report even on pass:** Name mechanism + threshold per category: `"login (app/api/auth/login/route.ts): @upstash/ratelimit 5/min per IP+email. signup (app/api/auth/register/route.ts): same limiter. password-reset (app/actions/auth.ts resetPasswordAction): same limiter. All three gated."`
- **Detail on fail:** `"signup and password-reset lack rate limits. login uses @upstash/ratelimit 5/min; app/api/auth/register/route.ts:8 calls supabase.auth.signUp() with no preceding limiter; app/actions/auth.ts:14 resetPasswordAction calls resetPasswordForEmail with no limiter. All three required."`
- **Cross-reference:** For enumeration-resistance, CAPTCHA escalation, IP reputation, and MFA enforcement, run `api-security`.
- **Remediation:**
  ```ts
  // app/api/login/route.ts
  import { Ratelimit } from '@upstash/ratelimit';
  import { kv } from '@vercel/kv';
  const limiter = new Ratelimit({ redis: kv, limiter: Ratelimit.slidingWindow(5, '1 m') });
  export async function POST(req: Request) {
    const { email } = await req.json();
    const ip = req.headers.get('x-forwarded-for') ?? 'unknown';
    const { success } = await limiter.limit(`login:${ip}:${email}`);
    if (!success) return new Response('Too many attempts', { status: 429 });
    // ...attempt login
  }
  ```
  Apply the same pattern to signup AND password-reset — all three need gating. For Supabase Auth, also configure `[auth.rate_limit]` in `supabase/config.toml`.

Taxons: project-snapshot, abuse-resistance

HTML version: https://auditbuffet.com/patterns/ab-002604
