LEAKCADE

Login rate limiting: your login route needs a limit

Updated 4 min read

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 login rate limiting unless you ask for it. Here's what a missing limit allows and how to add one in Next.js, Express and FastAPI.

QUICK CHECKLIST

  • Limit per IP and per account
  • Use a shared store (Redis), not memory, on serverless
  • Cover reset, OTP and verify-code routes
  • Return 429 with a short message
  • Cap routes that call paid AI APIs

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.

How an unlimited login gets brute forcedA script sends thousands of password guesses to the login route, nothing slows it down, one guess matches, and the account is taken over.SCRIPTSends thousandsof passwordguesses/API/LOGINNo limit, everyguess checkedMATCHOne leakedpassword worksLIMITED429 after 5 triesper 15 minSCRIPTSends thousands of password guesses/API/LOGINNo limit, every guess checkedMATCHOne leaked password worksLIMITED429 after 5 tries per 15 min

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.

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 login rate limiting

  1. Pick a shared store

    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 Redis (Upstash on Vercel).

  2. Limit per IP and per account

    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." Use both keys.

  3. Return 429 and keep lockouts short

    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.

  4. Cover every auth route

    Login, signup, password reset, magic link, OTP send and verify-code all need a limit.

  5. Test it

    Write a test where the 6th failed login in a row gets a 429.

Rate limit a Next.js login route

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
}

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. Keep the AI key itself on the server, out of public env variables. Login and signup server actions need the same limits as API routes.

Fix prompt you can paste

FIX PROMPT
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

  1. OWASP Authentication Cheat Sheet
  2. Supabase docs: Auth rate limits
  3. Upstash Ratelimit docs
  4. express-rate-limit on GitHub
  5. slowapi docs

FAQ

How many login attempts should I allow?

There's no official number. Five attempts per 15 minutes per IP and per account is a common starting point. Tune it once you see real traffic.

Does Supabase Auth rate limit logins?

Yes, by IP on its own endpoints. If your server calls Supabase for the user, forward the client IP with Sb-Forwarded-For, or every user shares your server's limit.

Should I lock accounts after failed logins?

Use short, temporary limits. OWASP warns that permanent lockouts let attackers lock real users out on purpose.

Does an in-memory rate limiter work on Vercel?

Not reliably. Each serverless instance has its own memory, so the count resets or splits. Use a shared store like Upstash Redis.

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 repo

Leakcade 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

Patch Notes by Leakcade

A short weekly email: what changed in AI coding tools, one real security mistake, one prompt you can reuse.