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.
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.
Use @upstash/ratelimit with Vercel KV (or Redis) to gate login, signup, and password-reset by IP + email:
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.
project-snapshot.abuse.rate-limit-on-auth-endpointshigh/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./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."package.json lacks @supabase/*, next-auth, @clerk/*, @auth0/*; no /api/auth/* handlers; no server actions named login / signup / resetPassword"."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.""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."api-security.// 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.