Rate Limiting and Abuse Control
AdvancedEvery action and handler is a public endpoint. Without a limit, a login form is a password-guessing API and an AI endpoint is someone else's free compute.
Overview
This is the concept that becomes urgent the first time an endpoint costs money. Server actions and route handlers are reachable by anyone with curl, and the ones that hurt are predictable: authentication, anything that sends an email or an SMS, and anything that calls a paid model. A limit is not primarily about traffic volume — it is about making a scripted attempt uneconomic. The implementation is straightforward on a shared store; the judgement is choosing the key, since limiting by IP alone punishes shared networks, which in India means punishing entire colleges.
Where a Limit Is Not Optional
The endpoints that get abused, and what each abuse looks like.
// 1. Login / OTP — credential stuffing and password spraying.
// Limit per account AND per IP: an attacker spreading attempts
// across thousands of accounts stays under a per-account limit.
// 2. Signup — fake accounts, often to farm a free tier.
// 3. Email and SMS — each send costs money, and an OTP endpoint with
// no limit is a way to bill you for someone else's SMS.
// 4. AI endpoints — the expensive one. An unlimited Aria endpoint is
// a free LLM proxy, and people do find them.
// 5. Password reset — enumeration plus mail cost.
// 6. Search and anything that hits the database hard.
// The economics: without a limit, one script can spend your monthly
// AI budget in an afternoon. With a limit, it cannot — which is why
// this belongs with security rather than with performance.
// Metered plan limits are a separate thing and also server-side. A
// client-side counter is a suggestion; the server's count is the
// limit. (Pro, Elite and Campus each have their own ceiling here.)Implementing One
A shared store, and the key you choose.
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, '60 s'),
prefix: 'login',
})
'use server'
export async function login(prevState, formData: FormData) {
const email = String(formData.get('email') ?? '')
const ip = (await headers()).get('x-forwarded-for')?.split(',')[0] ?? 'unknown'
// per account AND per IP
const [byEmail, byIp] = await Promise.all([
limiter.limit(`email:${email}`),
limiter.limit(`ip:${ip}`),
])
if (!byEmail.success || !byIp.success) {
return { error: 'Too many attempts. Try again in a minute.' }
}
…
}
// It MUST be a shared store. An in-memory Map resets on every cold
// start and is per-instance, so on serverless it barely limits
// anything.
// For a signed-in user, key on the user id — an IP limit punishes a
// whole college behind one NAT, which is a real scenario here.
const key = user ? `user:${user.id}` : `ip:${ip}`
// Return 429 with Retry-After from a route handler:
return new NextResponse('Too many requests', {
status: 429,
headers: { 'Retry-After': '60' },
})Beyond Counting
The layers around the limiter.
// Edge first. Vercel's WAF, Cloudflare rules and bot protection stop
// abusive traffic before it reaches a function you pay for — cheaper
// than any application-level limit.
// A cost ceiling, not just a rate: a per-user monthly cap on AI usage
// bounds the worst case even if the rate limit is evaded.
if (await monthlyUsage(user.id) >= limitFor(user.plan)) {
return { error: 'limit_reached' }
}
// Progressive friction rather than a hard wall:
// attempts 1-3 allow
// 4-6 add a delay
// 7+ require a CAPTCHA
// sustained lock the account and email the owner
// Make the response uninformative. "Too many attempts" for both a
// wrong password and a rate limit; a distinct message tells an
// attacker which emails exist.
// Log and alert on the SHAPE of failures, not just the count:
// a spike in 401s from one IP -> credential stuffing
// a spike in 429s -> a script, or a broken client
// one account, many IPs -> a shared or sold credential
// And bound the cost of a single request too: a timeout on the AI
// call, a max token count, and a maximum input size.Key Points to Remember
- 1Server actions and route handlers are public endpoints — login, OTP, email, reset and AI calls all need limits
- 2An unlimited AI endpoint is a free LLM proxy and the fastest way to spend a month's budget
- 3Rate limiting must use a shared store; an in-memory map is per-instance and resets on cold start
- 4Key on the user id when signed in — an IP-only limit punishes everyone behind one shared connection
- 5Add a monthly cost ceiling as well as a rate limit, and keep the error message uninformative
Interview Questions
Sign in to ask AriaWhich endpoints in a Next.js app most need rate limiting, and why?
Why does an in-memory rate limiter fail on serverless?
What is wrong with rate limiting by IP address alone?
Ask Aria about Rate Limiting and Abuse Control
Your personal AI tutor — ask anything about this concept
Revision Status
Personal Notes
Sign in to save personal notes for this topic.
Discussion
Sign in to join the discussion.