No rate limiting on login or your AI endpoint: how to add it
By Obscura Security · Updated · Next.js, Vercel, Supabase, OpenAI, Anthropic
Short answer
Without rate limits, a script can try thousands of passwords or one-time codes, flood your password-reset emails, or call your OpenAI or Anthropic route in a loop and run up your bill. Require a login on any route that costs you money, limit requests per IP address and per account, and set a spend cap with your AI provider.
If your app has a login form, a password reset, a one-time code, or an API route that calls OpenAI or Anthropic, find out what happens when someone sends that request 1,000 times in a minute. In many AI-built apps, nothing stops them.
Why it matters
Logins and codes. A six-digit one-time code has only a million possible values. A script can work through them quickly if nothing slows it down. Passwords get the same treatment, using lists of passwords leaked from other sites.
Password reset and sign-up. Each request sends an email from your domain. With no limit, someone can fill a stranger’s inbox with your emails, use up your email provider’s quota, and hurt your domain’s sending reputation.
AI routes. A route like /api/chat calls a paid API with your key on every request. If it needs no login and has no limit, the bill belongs to you and the person calling it pays nothing.
AI coding tools build what you ask for, and “make the chat work” doesn’t include “stop one person calling it ten thousand times”.
How to check your own app
Do these only against your own app, with a test account, and keep the loops small. Every successful AI call costs real money.
- Open your app with the browser’s developer tools on the Network tab and trigger the action: send a chat message, submit the login form. Note the URL of the request.
- Call it repeatedly from a terminal. For an AI route:
for i in $(seq 1 30); do
curl -s -o /dev/null -w "%{http_code}\n" -X POST https://YOUR-APP.com/api/chat \
-H "Content-Type: application/json" \
-d '{"message":"hi"}'
done
If every line says 200, there is no limit. A protected route starts returning 429 (Too Many Requests) after a few calls.
- Call the same route with no login cookie or
Authorizationheader. If it still answers, anyone on the internet can use it. - In the Supabase dashboard, look at the Rate Limits settings under Authentication, and check the Attack Protection settings for CAPTCHA.
- In your OpenAI or Anthropic account, look for a spend limit or budget setting, and for whether auto top-up is on.
The fix
1. Require a login on any route that costs money
Check the user’s session at the top of the route, before you call the AI provider. Then you can limit by user, which is far harder to dodge than limiting by IP.
2. Add a rate limit in the route
Counters kept in your server’s memory don’t work on Vercel or other serverless hosts, because each request may run in a fresh instance. Use a shared store. Upstash Redis with @upstash/ratelimit is a common choice:
npm install @upstash/ratelimit @upstash/redis
Create a Redis database in Upstash and put its REST URL and token in UPSTASH_REDIS_REST_URL and UPSTASH_REDIS_REST_TOKEN. Then:
// app/api/chat/route.ts
import { Ratelimit } from "@upstash/ratelimit";
import { Redis } from "@upstash/redis";
const ratelimit = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(10, "1 m"), // 10 requests per minute
prefix: "rl:chat",
});
export async function POST(req: Request) {
const user = await getUserOrNull(req); // your own session check
if (!user) return new Response("Unauthorised", { status: 401 });
const { success, reset } = await ratelimit.limit(user.id);
if (!success) {
const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000));
return new Response("Too many requests", {
status: 429,
headers: { "Retry-After": String(retryAfter) },
});
}
// ...now call OpenAI or Anthropic
}
For routes with no logged-in user, such as a login or reset route you wrote yourself, limit by IP and by the account being targeted:
const ip = req.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? "unknown";
const { success } = await ratelimit.limit(`${ip}:${email.toLowerCase()}`);
3. Use the limits and CAPTCHA that Supabase Auth provides
If your frontend calls Supabase Auth directly (supabase.auth.signInWithPassword and similar), those requests never pass through your code, so you can’t add your own limiter. Open the Rate Limits settings under Authentication and tighten anything that is looser than your real traffic needs. Under Attack Protection, turn on CAPTCHA with Cloudflare Turnstile or hCaptcha, then pass the token from the widget:
await supabase.auth.signUp({
email,
password,
options: { captchaToken },
});
4. Set spend caps at the provider
A limit in your code is the first layer, and a cap at the provider is the one that holds if the code is wrong. Use prepaid credits and leave auto top-up off if your provider offers that, and set a monthly budget or spend limit with alerts. Check whether your limit actually stops requests or only sends an email. Also set a maximum output length on every call (max_tokens on Anthropic’s API, max_completion_tokens or max_output_tokens on OpenAI’s, depending on the endpoint), and cap how long the user’s input can be.
Mistakes to avoid
- A counter in a JavaScript variable or
Map. On serverless hosts it resets constantly and limits nothing. - Limiting only in the frontend. Disabling a button doesn’t stop someone calling the URL directly.
- Limiting by IP alone. Offices and phone networks share IPs, and attackers rotate them. Use IP plus account.
- Trusting
x-forwarded-foreverywhere. On Vercel the platform sets it. Behind your own server, a client can send its own value unless your proxy overwrites it. - Different error messages for “no such account” and “wrong password”. They let an attacker work out who uses your app. Return the same message for both.
Common questions
- How many requests per minute should I allow?
- It depends on the route, but these are sensible starting points: 5 to 10 failed logins per 15 minutes for each account and IP combination, and 10 to 20 AI requests per minute for each signed-in user. Start strict and loosen the limit if real users hit it.
- Is Supabase login rate limited by default?
- Supabase Auth has built-in rate limits that you can review and adjust in the dashboard. They are generic, though, and they only cover Supabase's own auth endpoints. Your own API routes, including the ones that call OpenAI or Anthropic, have no limit unless you add one.
- Doesn't Vercel or Cloudflare handle this for me?
- Both offer rate limiting rules at the edge, which help against floods. They don't know who your users are, so keep a per-account limit in your own code as well.
- Can someone really run up my AI bill?
- Yes, if the route that calls the AI provider has no login and no limit. The URL is visible in the browser's network tab, so anyone can copy it and call it in a loop. Every call is billed to your key.
Read next
Written by Obscura Security, an Australian practice that checks web apps for problems like this one. If you want a second pair of eyes on yours, email [email protected].