VibeSecurity

Guide

Rate limiting Next.js API routes with Upstash Redis

Without rate limits, a login form can be guessed at all day, an OTP endpoint can be used to drain your SMS budget and an AI route can run up your provider bill. In-memory counters do not work on serverless hosting, so you need a shared store. This guide designs limits per route type and implements them in Next.js with Upstash Redis. You will finish with routes that answer 429 with a Retry-After header and a curl test that proves it.

By the VibeSecurity team5 min read

What you need and what you will have at the end

You need a Next.js app using the App Router, a free Upstash Redis database from the Upstash console and Node with npm. Serverless functions do not share memory between invocations, which is why a plain object counter fails and a shared Redis store is needed.

At the end each sensitive route will refuse excess requests with a 429 status and tell the client when to retry.

Step 1: Decide limits per route type

Set limits by what an attacker gains and what a request costs you. The numbers below are starting points to tune against your real traffic, not standards. Count login attempts per account and per IP separately, because one attacker can spread guesses across many accounts and many attackers can aim at one account.

Starting limits, adjust to your traffic
Route typeKeySuggested windowWhy
LoginIP and account5 attempts per minute per pair, wider per IPSlows password guessing
OTP sendPhone or email, and IP3 per 10 minutes per recipientStops SMS and email cost abuse
OTP verifyAccount5 per 10 minutesSix-digit codes are guessable if unlimited
Paid or LLM APIUser idPer minute burst plus per dayCaps provider bill
General APIIP60 per minuteBaseline scraping guard

Step 2: Install and configure Upstash

Install the two packages. In the Upstash console, create a Redis database and copy its REST URL and token into your environment. The client reads UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN when you call Redis.fromEnv(). Add the same variables in your hosting provider settings, as server-side variables without any public prefix.

Terminal and .env.local
npm install @upstash/ratelimit @upstash/redis

UPSTASH_REDIS_REST_URL=your-rest-url
UPSTASH_REDIS_REST_TOKEN=your-rest-token

Step 3: Build one helper

Create one file that defines a limiter for each route type and a function that applies it and builds the 429 response. The sliding window algorithm smooths out bursts at window edges. The limit call returns success, limit, remaining and reset, where reset is a Unix timestamp in milliseconds for when the window frees up. The helper converts it to seconds for Retry-After, which the HTTP specification defines as either a date or a number of seconds.

Take the client address from the header your hosting platform sets, and confirm which header that is for your host. A forwarded header the client can set freely is spoofable, so use the value your platform's edge writes. Fall back to a fixed label so a missing header cannot skip the limit.

lib/rate-limit.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";

const redis = Redis.fromEnv();

export const limiters = {
  login: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(5, "1 m"),
    prefix: "rl:login",
  }),
  otpSend: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(3, "10 m"),
    prefix: "rl:otp-send",
  }),
  paid: new Ratelimit({
    redis,
    limiter: Ratelimit.slidingWindow(10, "1 m"),
    prefix: "rl:paid",
  }),
};

export function clientIp(request: Request): string {
  const forwarded = request.headers.get("x-forwarded-for");
  return forwarded ? forwarded.split(",")[0].trim() : "unknown";
}

export async function enforce(
  limiter: Ratelimit,
  key: string
): Promise<Response | null> {
  const { success, reset } = await limiter.limit(key);
  if (success) return null;
  const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
  return Response.json(
    { error: "Too many requests" },
    { status: 429, headers: { "Retry-After": String(retryAfter) } }
  );
}

Step 4: Apply it in a route handler

Call the helper at the top of the handler, before any expensive work such as a database query, password hash or provider call. For login, key on IP plus the submitted email so one pair is throttled, and add a second, looser check on the IP alone so guessing across many emails is also slowed. For authenticated routes, key on the user id from the verified session.

Normalize the account part of the key, for example by lowercasing an email, so that variations do not get separate counters.

app/api/login/route.ts
import { limiters, clientIp, enforce } from "@/lib/rate-limit";

export async function POST(request: Request) {
  const body = await request.json();
  const email = String(body.email ?? "").trim().toLowerCase();
  const ip = clientIp(request);

  const blocked =
    (await enforce(limiters.login, ip + ":" + email)) ??
    (await enforce(limiters.login, "ip:" + ip));
  if (blocked) return blocked;

  return Response.json({ ok: true });
}

Step 5: Test with a curl loop on your own app

Run the app locally or on staging and send more requests than the limit within the window. The first five should return 200 and later ones 429. Print only the status codes, then look at one blocked response in full to confirm the header. Only run this against your own app.

Terminal
for i in $(seq 1 8); do
  curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:3000/api/login \
    -H "Content-Type: application/json" \
    -d '{"email":"test@example.com","password":"x"}'
done

curl -i -X POST http://localhost:3000/api/login \
  -H "Content-Type: application/json" \
  -d '{"email":"test@example.com","password":"x"}'

Common mistakes

How to verify

The pass condition is that the curl loop shows 200 up to your limit and 429 afterward, the blocked response carries a Retry-After value in seconds, and after that many seconds a request succeeds again. Check the Upstash data browser to confirm keys with your prefixes appear. Then confirm the limit applies on the deployed app and not only locally.

Keep it working

Add a limiter to every new route that sends messages, checks credentials or calls a paid provider. Review real 429 counts after launch and adjust limits, and rotate the Upstash token if it is ever exposed. For traffic-level abuse, also consider rate limiting rules at your CDN or firewall in front of the app.

Frequently asked questions

Why not use a simple in-memory counter?

Serverless and multi-instance deployments do not share memory. Each instance would keep its own counter, and counters reset when instances restart, so an attacker would effectively get many times the limit. A shared store such as Redis keeps one count across all instances.

What should the Retry-After header contain?

Either an HTTP date or a whole number of seconds to wait, according to the HTTP specification. The helper in this guide sends seconds. Support among clients is uneven, so the JSON body should also make the situation clear to users.

Should the limiter fail open or closed if Redis is down?

Decide per route. Fail closed on login and OTP routes, where an outage would otherwise remove your protection, and consider failing open on low-risk read routes. The Upstash library also has a timeout option that lets requests pass if the check is too slow, so set it deliberately.

Is IP-based limiting enough?

No. Attackers rotate addresses, and shared networks put many legitimate users behind one. Combine IP with the account or user id, and add limits where the cost sits, such as per-recipient limits on OTP sends.

Sources

  1. 1.Upstash ratelimit-js README
  2. 2.Upstash docs: Ratelimit features
  3. 3.MDN: Retry-After header
  4. 4.Next.js docs: Route Handlers (route.js)
  5. 5.OWASP API Security: Unrestricted Resource Consumption