Your AI-Built API Has No Rate Limit: The $2,000 Bill Waiting to Happen (Next.js, With Code)

July 25, 2026 · 2294 words

Next.js API rate limiting is the security feature your AI assistant almost never writes unprompted — and unlike a broken page, you don't find out it's missing until the invoice arrives. You vibe code an AI app. It works. You post it somewhere. Then someone writes a ten-line script that hits /api/chat a few thousand times an hour, and every one of those requests spends your money at OpenAI, Anthropic, Resend, or Twilio. Nothing crashes. Nothing looks hacked. Your balance just quietly goes to zero.

This guide shows the unlimited route handler AI tools produce by default, the fixed version using @upstash/ratelimit, and a curl loop that proves the limit works in about thirty seconds.

The pattern is well documented: OWASP lists it as API4:2023 Unrestricted Resource Consumption, rating its prevalence "widespread" and its detectability "easy" — meaning attackers find these endpoints without trying hard. OWASP's own example scenario is a "forgot password" flow that called a paid SMS provider at $0.05 per message; an attacker scripted the endpoint tens of thousands of times and, in their words, it led "the company to lose thousands of dollars in a matter of minutes." Swap SMS for LLM tokens and you have the modern vibe-coding version.

What rate limiting actually is

Rate limiting means counting how many requests each caller makes in a time window and refusing the extras. "Ten messages per minute per user." That's it.

The important word is per caller. To count per caller you need an identifier — something that tells one user apart from another. A logged-in user's ID is the good one. An IP address is the fallback for anonymous traffic. Without an identifier, you're either limiting nobody or limiting everybody together (one heavy user then locks out your whole app).

When someone goes over, you return HTTP status 429 Too Many Requests — the standard "slow down" response — instead of doing the expensive work.

The code your AI wrote

Ask Cursor or Claude Code for "an API route that sends the user's prompt to OpenAI and returns the answer" and you get something like this:

// app/api/chat/route.ts — the version your AI wrote (NO LIMITS)
import { NextResponse } from 'next/server'
import OpenAI from 'openai'

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })

export async function POST(req: Request) {
  const { prompt } = await req.json()

  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: prompt }],
  })

  return NextResponse.json({ reply: completion.choices[0].message.content })
}

This is correct code. It does exactly what you asked. The problem is what you didn't ask: nothing here caps how often it can run, who can run it, or how long the prompt is allowed to be.

Why this is dangerous

Your route is a public URL. Anyone who opens your site's network tab sees it. From there, the attack is not clever — it's a for loop:

# an attacker's entire toolkit
for i in $(seq 1 5000); do
  curl -s -X POST https://your-app.vercel.app/api/chat \
    -H "Content-Type: application/json" \
    -d '{"prompt":"write a 2000 word essay about anything"}' &
done

Three things happen at once, and none of them page you:

  1. Your API credits burn. Every request is a real, billed model call on your key. Long prompts and long outputs cost more, so attackers ask for long outputs.
  2. Your app becomes a free proxy. People genuinely do this — find an unauthenticated AI endpoint and wire it into their own product. Your key, their users.
  3. Real users get slow or broken responses while your provider throttles you for hitting their rate limits.

The same missing-limit problem shows up outside AI apps too: login routes get credential-stuffed, "forgot password" routes get used to spam someone's inbox, signup routes get filled with bot accounts, and search endpoints get scraped. It's the resource-consumption cousin of the missing authorization checks in our Next.js IDOR guide — a public entrance where the server forgot to ask a question.

Diagram comparing a Next.js API route without rate limiting, where a bot script sends thousands of requests straight to a paid AI provider, against a rate-limited route that returns 429 Too Many Requests

The fix: rate limit the route in three steps

We'll use @upstash/ratelimit, because it talks to Redis over HTTP instead of a TCP connection — which is what you need on Vercel and other serverless platforms where your function may be a brand-new instance on every request. An in-memory counter (const counts = new Map()) looks like it works locally and then silently does nothing in production, because each serverless instance gets its own empty Map.

Step 1 — Install and configure

npm install @upstash/ratelimit @upstash/redis

Create a Redis database in the Upstash console and put its two values in .env.local. The names matter — Redis.fromEnv() reads exactly these:

UPSTASH_REDIS_REST_URL=****
UPSTASH_REDIS_REST_TOKEN=****

Now define your limiters in one shared file, outside the request handler, so the instance is reused:

// lib/ratelimit.ts
import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const redis = Redis.fromEnv()

// Expensive AI calls: 10 per minute per user
export const chatLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(10, '60 s'),
  analytics: true,
  prefix: 'ratelimit:chat',
})

// Auth endpoints deserve a much tighter limit
export const authLimiter = new Ratelimit({
  redis,
  limiter: Ratelimit.slidingWindow(5, '300 s'),
  analytics: true,
  prefix: 'ratelimit:auth',
})

slidingWindow(10, '60 s') means ten requests per rolling sixty seconds. The prefix keeps each limiter's keys separate in Redis so your chat limit and your login limit don't share a counter.

Step 2 — Get a real identifier

Here's a trap specific to Next.js 15 and later: request.ip no longer exists. Vercel removed ip and geo from NextRequest in Next.js 15, so any AI-generated snippet using req.ip is out of date and will give you undefined — which quietly rate-limits every visitor as one shared bucket. Read the header instead:

// lib/identify.ts
import { headers } from 'next/headers'

export async function getClientIp() {
  const h = await headers() // headers() is async in Next.js 15+
  const forwarded = h.get('x-forwarded-for')
  return forwarded?.split(',')[0]?.trim() ?? '127.0.0.1'
}

Prefer a user ID whenever the user is logged in — IPs are shared by offices, schools, and phone networks, and a determined attacker rotates them. Use the IP only as the anonymous fallback.

Step 3 — Enforce it in the route

// app/api/chat/route.ts — the fixed version
import { NextResponse, after } from 'next/server'
import OpenAI from 'openai'
import { auth } from '@/lib/auth'
import { chatLimiter } from '@/lib/ratelimit'
import { getClientIp } from '@/lib/identify'

const openai = new OpenAI({ apiKey: process.env.OPENAI_API_KEY })

export async function POST(req: Request) {
  // 1. Require a logged-in user (auth first, always)
  const session = await auth()
  if (!session?.user) {
    return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
  }

  // 2. Rate limit per user, falling back to IP
  const identifier = session.user.id ?? (await getClientIp())
  const { success, limit, remaining, reset, pending } =
    await chatLimiter.limit(identifier)

  // Let analytics finish after the response is sent
  after(async () => { await pending })

  if (!success) {
    return NextResponse.json(
      { error: 'Too many requests. Please wait a moment.' },
      {
        status: 429,
        headers: {
          'Retry-After': Math.ceil((reset - Date.now()) / 1000).toString(),
          'X-RateLimit-Limit': limit.toString(),
          'X-RateLimit-Remaining': remaining.toString(),
          'X-RateLimit-Reset': reset.toString(),
        },
      },
    )
  }

  // 3. Cap the input size too — cost scales with prompt length
  const { prompt } = await req.json()
  if (typeof prompt !== 'string' || prompt.length > 2000) {
    return NextResponse.json({ error: 'Invalid prompt' }, { status: 400 })
  }

  const completion = await openai.chat.completions.create({
    model: 'gpt-4o-mini',
    messages: [{ role: 'user', content: prompt }],
    max_tokens: 800, // cap the output too
  })

  return NextResponse.json({ reply: completion.choices[0].message.content })
}

Three things to notice. Auth runs before the limiter, so unauthenticated junk is rejected before it costs you a Redis call. after() — stable since Next.js 15.1 — lets the pending promise finish its analytics write after the response is sent, which matters on serverless where the runtime otherwise shuts down mid-write. And the input and output caps are part of the same defence: OWASP explicitly lists maximum payload sizes alongside request-frequency limits, because a single enormous prompt can cost more than a hundred small ones.

Request lifecycle diagram showing where the rate limit check belongs in a Next.js API route: request, auth check, rate limit check, input validation, then the paid AI provider call

How to test it in 30 seconds

Run npm run dev, then fire fifteen requests at a limit of ten and watch the status codes flip:

for i in $(seq 1 15); do
  curl -s -o /dev/null -w "%{http_code} " -X POST http://localhost:3000/api/chat \
    -H "Content-Type: application/json" \
    -d '{"prompt":"hi"}'
done; echo

You want to see roughly 200 200 200 ... 429 429 429. If you get fifteen 200s, the limiter isn't wired up. If you get fifteen 429s, your identifier is probably undefined and everyone is sharing one bucket — check getClientIp().

To confirm it survives a deploy, run the same loop against your production URL from two different networks (your laptop and your phone's hotspot). Each should get its own allowance when logged in as different users.

Risk table

RiskWhat can happenFix
No rate limit on a paid API callOne script drains your OpenAI/Twilio balance overnight@upstash/ratelimit with a per-user identifier
In-memory counter (Map) on serverlessLimit silently does nothing in productionUse Redis-backed limiting shared across instances
Using req.ip on Next.js 15+undefined identifier — everyone shares one bucketRead x-forwarded-for, or ipAddress() from @vercel/functions
Limiting by IP onlyOffices share IPs; attackers rotate themPrefer the session user ID, IP as fallback
No auth on the endpointYour app becomes a free public AI proxyCheck the session before the expensive call
Unbounded prompt or response lengthOne request costs as much as hundredsValidate input length and set max_tokens
No spending cap at the providerNothing stops the bill mid-attackSet hard usage limits and billing alerts in the provider dashboard
Same limit on login and on searchBrute-force gets the same generous allowance as browsingSeparate, stricter limiters for auth routes

That last row is worth acting on today, separately from any code: OWASP's prevention list ends with "configure spending limits for all service providers/API integrations." Rate limiting is your lock; the provider's spending cap is your insurance. Set both.

Ask your AI to fix it

Paste this into Cursor, Claude Code, or Copilot:

Add rate limiting to my Next.js App Router API routes.
1. Install @upstash/ratelimit and @upstash/redis. Create lib/ratelimit.ts exporting
   separate Ratelimit instances (Redis.fromEnv(), Ratelimit.slidingWindow) with distinct
   `prefix` values: 10/60s for AI or other paid calls, 5/300s for auth routes.
2. In each route handler, check the session FIRST and return 401 if missing, then call
   limiter.limit(identifier) where identifier is the session user id, falling back to the
   first entry of the x-forwarded-for header. Do NOT use req.ip — it was removed in Next.js 15.
3. On failure return status 429 with Retry-After, X-RateLimit-Limit, X-RateLimit-Remaining
   and X-RateLimit-Reset headers.
4. Pass the `pending` promise to after() from next/server so analytics complete on serverless.
5. Also validate request body size / string length and set an output token cap on model calls.
Do not use an in-memory Map or a module-level counter — it does not work across serverless
instances. Show me the diff before applying it.

The last two lines matter most: an in-memory counter is the single most common thing an AI will hand you here, and it passes local testing perfectly while doing nothing in production.

Rate limiting checklist

Next.js API rate limiting checklist card listing auth before limiting, Redis-backed limiter, per-user identifier, 429 with Retry-After header, input and output caps, provider spending limits, and tested with a curl loop

  • Every route that costs money or sends email/SMS has a limiter
  • Limiter is Redis-backed, not an in-memory Map
  • Identifier is the session user ID, with IP only as fallback
  • No req.ip anywhere (removed in Next.js 15)
  • Auth check runs before the rate-limit check
  • Login, signup, and password-reset routes have their own stricter limiter
  • Over-limit returns 429 with a Retry-After header
  • Prompt/body length validated and model output capped
  • Hard spending limit + billing alert set at every paid provider
  • Verified with a curl loop against the deployed URL, not just localhost

Rate limiting is one of the checks every public entrance to your app needs to make on the server — the same principle behind our vibe coding security pillar guide. Pair it with real authentication (see our auth tools roundup) and server-side authorization checks, because a limiter only slows an attacker down; it doesn't decide whether they were allowed in at all.

Shipping an AI app built with Cursor or Claude Code and unsure which of your endpoints would survive a scripted loop? I do hands-on code-security audits of vibe-coded apps — I run the abuse scripts against your routes and send back the specific fixes. Email [email protected] with your stack and I'll take a look.

Key Takeaway

Your AI writes routes that work, not routes that survive strangers. Any endpoint that spends money — model calls, emails, SMS, image generation — needs a Redis-backed limit keyed to a real user, an auth check ahead of it, a cap on input and output size, and a spending limit at the provider. That's an afternoon of work against a bill you can't undo.