A login route has two jobs. One is to check that the password is correct, and AI tools do this well because it is the visible feature. The other is to stop someone from guessing the password over and over, and AI tools almost never do this, because the login works perfectly without it. That second job is rate limiting, and its absence turns a weak password anywhere in your user base into an easy break-in.
Why the missing limit matters
Without a limit, an attacker can send attempts as fast as your server answers. That enables two common attacks. The first is brute forcing, working through likely passwords against one account. The second, and more effective today, is credential stuffing: replaying username and password pairs leaked from other sites, betting that some of your users reused them. Both come down to volume, and volume is exactly what an unlimited endpoint allows.
The reason it survives to production is that it is invisible in normal use. A real user logs in once and everything works, so a functional test passes and the gap never shows. It only appears when someone points a script at the route, and by then it is live. This is the same works-in-development blind spot behind the insecure defaults AI tools ship.
Want to see exactly what Prbl flags? Watch it scan a demo app, no repo or account needed.
See a live scan →How to add it
A rate limiter tracks how many requests a client makes in a window of time and rejects further ones once a threshold is passed. On auth routes you want a short window and a low count, so a handful of failed attempts slows to a crawl while a real user never notices.
import rateLimit from "express-rate-limit";
const loginLimiter = rateLimit({
windowMs: 60 * 1000, // one minute
max: 5, // 5 attempts per IP per window
standardHeaders: true,
legacyHeaders: false,
});
app.post("/login", loginLimiter, handleLogin);That is the whole change on a single server. The important detail is what you key the limit on: use the real client IP, not a header a client can set, or an attacker just changes the header to get a fresh allowance on every request. For actions taken while logged in, key on the account too. The full walkthrough is in the fix for a login with no rate limiting, and the concept is covered in what rate limiting is.
Beyond the login
Once the auth routes are covered, extend the idea to anywhere volume alone is the attack. Password reset and code-sending endpoints deserve tight limits because each request has a cost or a security value. Anything that calls a paid third-party API should be limited so a burst of requests cannot run up your bill. The pattern is the same; only the window and count change.
Frequently asked questions
Doesn't a strong password make rate limiting unnecessary?
No, because you do not control your users' passwords, and many reuse ones that have already leaked. Rate limiting is what protects the weak and reused passwords in your user base, which you cannot fix from your side. It also defends against credential stuffing, where attackers replay username and password pairs from other breaches, and that works no matter how strong your own password policy is.
Where exactly should I add rate limiting?
Start with the routes where a single guess has value: login, password reset, and any endpoint that sends a code or email. Then extend it to expensive operations, like anything that calls a paid API, where the cost is the attack. A short window with a low count on auth routes, and a broader limit on general API traffic, covers most of the risk.
How do I rate limit correctly behind a proxy or CDN?
Key the limit on the real client IP, not a header a client can set. Behind a proxy or CDN the socket address is the proxy, so you configure your framework to trust the proxy and read the forwarded client IP it sets, while ignoring client-supplied headers. Getting this wrong either lets an attacker spoof a new identity per request or accidentally limits all users as one. For logged-in actions, key on the account as well.
Is in-memory rate limiting good enough?
For a single server it is a fine start. The limit lives in that process's memory, which resets on restart and is not shared across instances, so once you run more than one server or a serverless setup, move the counter to a shared store like Redis so the limit is enforced across all of them. Begin simple, but know that in-memory does not hold once you scale horizontally.
Check your own auth routes
If your login was generated by an AI tool, the odds it shipped with rate limiting are low, and it is one of the patterns we look for. A scan reads the repo and flags an auth route with no limit, so you can add the few lines before someone runs a script against it. Run a free scan and check, or work it into a standing review pass.