What happens when your API has no rate limiting
By Seaworthy · 26 July 2026
Somewhere on the internet right now, a script is hitting /api/login a few thousand times a minute with a list of leaked credentials from a breach that has nothing to do with your app. It doesn't know or care what your app does. It just knows your endpoint accepts a username and password and returns a response, and that's enough.
If there's no rate limiting on that route, nothing stops it. Nothing slows it down. Nothing even notices, until your database connection pool is exhausted or your hosting bill has a very bad day.
Credential stuffing doesn't need to be clever
Credential stuffing is not sophisticated. Attackers take a dump of email/password pairs from some other company's breach and throw them at your login endpoint, betting that a slice of your users reused a password. It works often enough that it's still one of the most common attack patterns against public login forms, years after everyone agreed passwords are bad.
The only thing standing between "annoying background noise" and "several accounts compromised" is how many attempts a single client can make per minute. If the answer is unlimited, you've built a password-guessing oracle and handed it to anyone who can write a for loop.
// vulnerable: no limit on login attempts
app.post('/api/login', async (req, res) => {
const { email, password } = req.body;
const user = await db.users.findByEmail(email);
if (!user || !(await bcrypt.compare(password, user.passwordHash))) {
return res.status(401).json({ error: 'Invalid credentials' });
}
res.json({ token: signToken(user) });
});
That handler is correct. It hashes properly, it returns a generic error, it does everything the tutorials tell you to do. And it will happily process ten thousand guesses a minute from the same IP because nothing in the code says otherwise.
It's not just login forms
Rate limiting gets framed as a login-page problem because credential stuffing is the scariest example, but any public endpoint without a limit has the same failure mode, just with a different attacker.
Scraping: if your /api/products or /api/search endpoint returns structured data, someone will eventually point a scraper at it and pull your entire catalog, your pricing, your listings, whatever you've got. Not maliciously, necessarily. They just don't see a reason to be polite about it, because you never gave them one.
Then there's the one nobody plans for: a single misbehaving client, not even an attacker, taking down shared infrastructure. A frontend with a retry loop that has no backoff. A mobile app with a bug that polls an endpoint every 100ms instead of every 10 seconds after an update goes out. A partner's integration that decides to backfill a year of data in a tight loop. None of these are attacks. All of them can flatten a shared database or exhaust an upstream API quota just as effectively as one.
If your API has no rate limiting, you're trusting every client, forever, to behave. That's not a security model, it's a hope.
Adding it in Express
You don't need to build this yourself. A token bucket, or the simpler fixed-window equivalent, is a solved problem, and for most apps a small library on top of an in-memory or Redis store is enough.
import rateLimit from 'express-rate-limit';
const loginLimiter = rateLimit({
windowMs: 15 * 60 * 1000, // 15 minutes
max: 5, // 5 attempts per IP per window
standardHeaders: true,
message: { error: 'Too many login attempts, try again later' },
});
app.post('/api/login', loginLimiter, async (req, res) => {
// same handler as before
});
Five attempts per fifteen minutes per IP won't stop a distributed attack using thousands of IPs, but it kills the cheap version of the attack stone dead, and it's two lines of middleware. For anything running on more than one instance, back it with Redis instead of the default in-memory store, or the limit resets every time a request lands on a different process.
Public, non-auth endpoints deserve a limit too, just a more generous one:
const apiLimiter = rateLimit({
windowMs: 60 * 1000,
max: 100,
});
app.use('/api/', apiLimiter);
Adding it in FastAPI
FastAPI doesn't ship a limiter, but slowapi, a Starlette-friendly port of Flask-Limiter, covers the same ground with a similar amount of code.
from fastapi import FastAPI, Request
from slowapi import Limiter, _rate_limit_exceeded_handler
from slowapi.util import get_remote_address
from slowapi.errors import RateLimitExceeded
limiter = Limiter(key_func=get_remote_address)
app = FastAPI()
app.state.limiter = limiter
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
@app.post("/api/login")
@limiter.limit("5/15minutes")
async def login(request: Request, credentials: LoginRequest):
user = await get_user_by_email(credentials.email)
if not user or not verify_password(credentials.password, user.password_hash):
raise HTTPException(status_code=401, detail="Invalid credentials")
return {"token": sign_token(user)}
Same idea, same tradeoffs. In-memory storage by default, Redis backend for anything running more than one worker process, and a limit that's tuned to the endpoint rather than copy-pasted everywhere at the same value. A login route wants a tight limit measured in single digits. A read-only search endpoint can afford to be looser.
What a reasonable default looks like
You don't need to solve rate limiting perfectly on day one. You need to not have zero. A few rules of thumb cover most cases.
Auth endpoints, login, password reset, signup, get the tightest limits, keyed by IP and ideally also by the account being targeted, so an attacker can't just distribute requests across usernames to dodge an IP-only rule. Public read endpoints get a much looser limit, mostly to protect against runaway clients rather than attackers. Anything that hits a paid third-party API on your behalf gets a limit that respects your own upstream quota, because your users will not enjoy finding out you got cut off by OpenAI mid-month.
None of this is exotic. It's middleware, a config value, and maybe a Redis instance you probably already have for sessions or caching.
Seaworthy's static analysis flags public route handlers with no rate limiting middleware applied, as part of its resilience checks.
This article was generated by AI and summarises publicly available sources.
Seaworthy scans your repo for the issues covered in articles like this one.
Security gaps, exposed secrets, and misconfigurations — caught before you deploy. Free to run, no account needed.