Your login has no rate limit
A login route that accepts unlimited attempts lets a script try passwords until one works. AI coding tools write login, signup and password-reset routes that work on the first try, and they rarely add a limit unless you ask for one.
What a script does with an unlimited login
OWASP's Authentication Cheat Sheet names the three usual attacks. Brute force tries many passwords against one account. Credential stuffing tests username and password pairs leaked from other sites. Password spraying tries one weak password against many accounts. All three need lots of requests, and a missing limit is what makes them cheap.
Reset and code routes have their own problems. Every call to /api/forgot-password or /api/send-code sends an email or SMS that you pay for, to whatever address the caller typed. A 6-digit code has a million possible values, so a verify route without a limit can be guessed by a loop.
Login routes are easy to find. /api/login, /api/auth/login and /api/signin cover most apps.
What we found in public repos
We searched public GitHub code for Next.js routes at app/api/login/route.ts that check passwords with bcrypt.compare and ran our rate-limit check on 10 of them. All 10 had no limiter in the route, and a repo-wide search found none elsewhere that covered the login route. In a control sample of 6 login routes that imported a limiter, the check stayed quiet on all 6.
Ten files is a small sample, so don't treat it as a rate.
If you use Supabase Auth, Clerk or Auth0
These providers limit their own endpoints. Supabase, for example, allows 150 token requests (password sign-ins and refreshes) per 5 minutes per IP by default, and the built-in email sender stops at 2 emails per hour. When the browser talks to Supabase directly, that caps how fast one IP can guess. It doesn't stop guessing spread over many IPs, so strong passwords and MFA still matter.
It changes when your own API route calls Supabase. Supabase then sees your server's IP for every user, so one attacker can use up the shared bucket and lock everyone out. Supabase's rate-limit docs describe the fix: forward the real client IP in the Sb-Forwarded-For header, with a secret key, after turning on IP forwarding in the dashboard.
How to add a limit in Next.js
Serverless instances don't share memory, so each one keeps its own counter and a script that hits several instances gets several times the limit. Use a shared store. Upstash Ratelimit works over HTTP and runs on Vercel:
import { Ratelimit } from "@upstash/ratelimit"
import { Redis } from "@upstash/redis"
const limiter = new Ratelimit({
redis: Redis.fromEnv(),
limiter: Ratelimit.slidingWindow(5, "15 m"),
})
export async function POST(req: Request) {
const { email, password } = await req.json()
const ip = req.headers.get("x-forwarded-for")?.split(",")[0] ?? "unknown"
const byIp = await limiter.limit(`login:ip:${ip}`)
const byAccount = await limiter.limit(`login:email:${String(email).toLowerCase()}`)
if (!byIp.success || !byAccount.success) {
return new Response("Too many attempts. Try again later.", { status: 429 })
}
// now check the password
}The second key limits attempts per account as well as per IP. OWASP recommends tying the failed-login counter to the account "rather than the source IP address, in order to prevent an attacker from making login attempts from a large number of different IP addresses." The same cheat sheet warns that lockouts can be abused to lock other people out, so keep the window short and let password reset still work.
Express and FastAPI
In Express, put express-rate-limit on the auth routes only:
import { rateLimit } from "express-rate-limit"
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000,
limit: 5,
standardHeaders: "draft-8",
legacyHeaders: false,
})
app.post("/api/login", loginLimiter, loginHandler)
app.post("/api/forgot-password", loginLimiter, forgotHandler)Its default store lives in memory. If you run more than one server, give it a Redis store so the counts are shared. In FastAPI, slowapi does the same job:
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.errors import RateLimitExceeded
from slowapi.util import get_remote_address
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/login")
@limiter.limit("5 per 15 minutes")
async def login(request: Request):
...Routes that call a paid AI API
A /api/chat route that forwards to OpenAI or Anthropic has the same problem with a bill attached. Anyone who finds it can loop on it and spend your credits. Limit it per signed-in user, add a daily cap, and reject requests from visitors who aren't logged in if the feature doesn't need them.
Fix prompt you can paste
Find every login, signup, password-reset, OTP/magic-link and verify-code route in this repo, plus every route that calls a paid AI API. Add a rate limit to each: 5 attempts per 15 minutes per IP and per account (email) for auth routes, a per-user limit and daily cap for AI routes. Use a shared store (Upstash Redis on Vercel, Redis for express-rate-limit, slowapi for FastAPI), return 429 with a short message, and don't lock accounts permanently. If a route forwards to Supabase Auth from the server, forward the client IP with Sb-Forwarded-For. Add a test that the 6th failed login in a row gets a 429.
How Leakcade checks this
Our rate-limit check reads your repo as text. It flags login, signup, reset and OTP routes that check passwords or send emails and codes themselves without any sign of a limiter, and routes that call paid AI APIs without one. A limiter in the same file, in middleware.ts or applied to the whole app counts. Limits set at your CDN or firewall (Vercel Firewall, Cloudflare) are invisible to us, and every finding says so.
Routes that only hand the login to Supabase, Clerk or Auth0 are skipped, because those providers limit their own endpoints. A NextAuth Credentials provider runs your own password check, so we check it like any login route.
Further reading
Sources
- OWASP Authentication Cheat Sheet
- Supabase docs: Auth rate limits
- Upstash Ratelimit docs
- express-rate-limit on GitHub
- slowapi docs
- Leakcade: Vibe coding security checklist
Want a second pair of eyes?
We review your whole repo and write a fix prompt for every finding. $19, report within an hour.
Check my repoLeakcade is an automated check. It can miss vulnerabilities and is not a substitute for a professional security audit or pentest. Provided as is. You stay responsible for your code. Where the law allows, our liability is limited to what you paid ($19). Terms