Next.js has no built-in rate limiting. Without it, login, signup, password reset, and Server Actions are vulnerable to brute force and credential stuffing. [CWE-799, CWE-307 · A04:2021]
Why This Matters
prevents brute force attacks, credential stuffing, and resource exhaustion
Impact: HIGH (prevents brute force attacks, credential stuffing, and resource exhaustion)
Next.js provides zero built-in rate limiting. Every login endpoint, password reset, signup form, OTP verification, and sensitive Server Action is vulnerable to brute force attacks by default. Credential stuffing is the #1 attack vector against authentication systems.
Without rate limiting, an attacker can:
Try thousands of password combinations per minute
Enumerate valid usernames via timing differences
Exhaust AI/API credits by spamming expensive endpoints
DoS your app by flooding Server Actions
Incorrect (no rate limiting):
// app/api/auth/login/route.ts// ❌ No rate limiting — attacker can try millions of passwordsexport async function POST(request: NextRequest) { const { email, password } = await request.json() const user = await db.user.findUnique({ where: { email } }) if (!user || !await verifyPassword(password, user.passwordHash)) { return NextResponse.json({ error: 'Invalid credentials' }, { status: 401 }) } // ... create session}
// ❌ Server Action with no rate limiting on expensive operation'use server'export async function analyzeCode(code: string) { const result = await callAI(code) // Costs money per call! return result}
Correct (rate limiting with Upstash Redis):
// lib/rate-limit.tsimport { Ratelimit } from '@upstash/ratelimit'import { Redis } from '@upstash/redis'const redis = Redis.fromEnv()// Different limiters for different sensitivity levelsexport const authLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(5, '15 m'), // 5 attempts per 15 minutes prefix: 'rl:auth',})export const apiLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), // 60 requests per minute prefix: 'rl:api',})export const expensiveLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(10, '1 h'), // 10 per hour prefix: 'rl:expensive',})
// app/api/auth/login/route.tsimport { authLimiter } from '@/lib/rate-limit'export async function POST(request: NextRequest) { // Rate limit by IP (or email for account-level limiting) const ip = request.headers.get('x-forwarded-for') ?? 'unknown' const { success, remaining, reset } = await authLimiter.limit(ip) if (!success) { return NextResponse.json( { error: 'Too many attempts. Try again later.' }, { status: 429, headers: { 'Retry-After': String(Math.ceil((reset - Date.now()) / 1000)), 'X-RateLimit-Remaining': String(remaining), }, } ) } const { email, password } = await request.json() // ... validate credentials}
// ✅ Rate limiting in Server Actions'use server'import { expensiveLimiter } from '@/lib/rate-limit'import { auth } from '@/lib/auth'import { headers } from 'next/headers'export async function analyzeCode(rawInput: unknown) { const session = await auth() if (!session?.user) throw new Error('Unauthorized') // Rate limit by user ID for authenticated actions const { success } = await expensiveLimiter.limit(session.user.id) if (!success) { return { error: 'Rate limit exceeded. Please wait before trying again.' } } // ... proceed with expensive operation}
Lightweight alternative (in-memory, for single-instance deployments):
// lib/rate-limit-memory.ts — no external dependenciesconst attempts = new Map<string, { count: number; resetAt: number }>()export function checkRateLimit(key: string, maxAttempts: number, windowMs: number) { const now = Date.now() const entry = attempts.get(key) if (!entry || now > entry.resetAt) { attempts.set(key, { count: 1, resetAt: now + windowMs }) return { success: true, remaining: maxAttempts - 1 } } if (entry.count >= maxAttempts) { return { success: false, remaining: 0, resetAt: entry.resetAt } } entry.count++ return { success: true, remaining: maxAttempts - entry.count }}