DEV Community

Juan Torchia
Juan Torchia Subscriber

Posted on • Originally published at juanchi.dev

Rate limiting in web apps: what to protect before picking a library

Rate limiting in web apps: what to protect before picking a library

I spent months convinced that rate limiting was just a matter of adding middleware, setting a request-per-minute number, and moving on. It took seeing a login endpoint with zero controls — in a reference project I was using to study architecture — to understand that the missing library wasn't the problem. Nobody had defined what was being protected, from what kind of abuse, and at what cost.

I'm not telling that story to look bad. I'm telling it because you're probably going to copy that upstash/ratelimit or express-rate-limit snippet before you ask yourself that question. And if you do, you'll have rate limiting that works on paper and fails exactly where it matters most.

My take: rate limiting is not a dependency. It's an abuse policy. And a policy without context is decoration.


Rate limiting in Next.js web apps: the real problem

Most rate limiting tutorials for Next.js start from the library. They show you how to configure Upstash Redis, how many tokens to put in the bucket, and how to return a 429. All of that is fine. The problem is that sequence skips four questions that should come first:

  1. What asset are you protecting? A login endpoint? A public API with no auth? A contact form? A route that fires an email? They don't all deserve the same policy.
  2. What kind of abuse are you expecting? Credential stuffing, scraping, form spam, and DDoS are four different problems. Rate limiting solves some of them, not all.
  3. What does a false positive cost you? Blocking a legitimate user at an e-commerce checkout has a very different cost than blocking a bot on a public query API.
  4. How will you know it's working? Without logs, without metrics, without alerts, the middleware acts in silence and you have no idea whether it's blocking what it should or what it shouldn't.

These four points aren't philosophical. They're the difference between a control that actually protects and one that gives you a false sense of security.


What OWASP says — and what it doesn't

The OWASP Authentication Cheat Sheet is the most-cited public reference when talking about defensive controls around authentication and abuse. Worth reading directly instead of trusting someone else's summary.

What OWASP explicitly says in that document:

  • Implement controls to limit failed login attempts to mitigate brute force attacks.
  • Consider temporary lockout, CAPTCHA, or user notification as possible responses.
  • Aggressive lockout can itself be used as a denial-of-service vector against legitimate users — meaning the false positive has a security cost, not just a UX one.

What OWASP doesn't say in that document:

  • How many requests per minute is the right number. That number doesn't exist outside of context.
  • That IP-based rate limiting is sufficient. An attacker with distributed infrastructure rotates IPs trivially.
  • That every route in an application deserves the same control.

This matters because a lot of people cite "OWASP recommends rate limiting" as if it were a universal recipe. It isn't. It's one control among several, with explicit preconditions and trade-offs.

If you've already read my post on what to expose and what to hide in Spring Boot Actuator, you'll recognize the pattern: the problem isn't enabling or disabling a feature. It's understanding what decision you're making and why.


Where the common recipe goes wrong

The typical setup in Next.js App Router looks like this: install @upstash/ratelimit, wrap the Route Handler or Server Action, configure 10 requests per minute per IP, return 429 if exceeded. It works. And that's exactly the problem: it works for the case that doesn't matter.

Hidden cost 1: IP-based granularity is weak where it hurts most.
The most sensitive endpoints — login, password recovery, registration — are precisely the ones a resourceful attacker will hit from multiple IPs. IP rate limiting stops amateur abuse. It doesn't stop well-crafted credential stuffing. For that you need to combine IP + User-Agent fingerprinting, or put CAPTCHA directly in the flow.

Hidden cost 2: a 429 without logging is a blind control.
If the middleware blocks and you don't emit a structured log with at minimum { ip, route, timestamp, reason }, you have no way to tell the difference between a real attack and a legitimate user behind a corporate NAT sharing an IP with a hundred colleagues. That legitimate user gets a 429 and has no idea why.

Hidden cost 3: a global config flattens the policy.
A global limit of 100 requests per minute might be generous for a login endpoint and absurdly restrictive for a public search API. When everything uses the same configuration, the policy has no real semantics — it's an arbitrary number nobody ever justified.

Concrete counterexample: a contact form that fires an email has a real per-request cost (the email send). There, rate limiting by IP + by session makes sense, and the threshold should be low. A public read endpoint serving already-cached static data has a completely different abuse profile. Modeling them the same way is just imprecise.

Same logic I went into with caching in Next.js App Router: there's no one config that works for everything. Every route has a behavioral contract.


Decision checklist: before you copy the middleware

This checklist doesn't replace per-system analysis. It's a starting point to structure the conversation before you open the library docs.

1. Define the asset

Route type Sensitivity Minimum recommended control
Login / authentication High Rate limit by IP + by user if it exists; consider CAPTCHA on retries
Password recovery High Limit by IP; notify user; don't reveal whether the email exists
Form with side effect (email, payment) Medium-High Rate limit by IP + session; log every block
Public read API without auth Medium Global rate limit by IP; consider whether caching already mitigates the risk
Authenticated API Low-Medium Rate limit by token/user; differentiated policy by plan if applicable
Static or health routes N/A No rate limit; false positive cost is maximum

Prudent criteria based on documented patterns. The numeric threshold depends on each system's real traffic volume.

2. Define the expected abuse

  • Brute force / credential stuffing: control at the authentication endpoint, not across the whole app.
  • Form spam: rate limit by IP + honeypot + CAPTCHA if the volume justifies it.
  • Scraping: rate limit by IP + User-Agent headers; consider whether the content should be public anyway.
  • Layer 7 DDoS: app-level rate limiting isn't enough; that problem lives at the infrastructure layer (CDN, WAF, Railway regions).

3. Measure the cost of the false positive

Before setting a threshold, answer: what happens if a legitimate user exceeds it? If the answer is "they lose a session they can recover easily," the threshold can be more aggressive. If the answer is "they lose a purchase or can't get into an active account," the threshold needs more tolerance — or a progressive challenge mechanism instead of a hard block.

4. Define observability from day one

A minimal snippet of what you should log on every block:

// app/api/login/route.ts — reproducible example, not production code
import { NextRequest, NextResponse } from 'next/server'

// Rate limit check goes BEFORE any business logic
export async function POST(req: NextRequest) {
  const ip = req.headers.get('x-forwarded-for') ?? 'unknown'
  const limitExceeded = await checkRateLimit(ip) // your implementation

  if (limitExceeded) {
    // ALWAYS log: IP, route, timestamp, reason
    console.warn(JSON.stringify({
      event: 'rate_limit_exceeded',
      ip,
      route: '/api/login',
      timestamp: new Date().toISOString(),
    }))
    return NextResponse.json({ error: 'Too many requests' }, { status: 429 })
  }

  // Authentication logic here
}
Enter fullscreen mode Exit fullscreen mode

Without that log, the middleware is a black box. You don't know if it blocked ten bots or ten real users. I covered the observability angle in the Docker healthchecks post too: measuring well is part of the control, not an optional extra.


Honest limits: what you can't conclude without your own data

I'm saying this explicitly because a lot of rate limiting content just skips it:

  • You can't know the right threshold without real traffic logs. The number 10, 100, or 1000 requests per minute means nothing out of context. If you don't have real usage data, start with a conservative threshold, log it, and adjust.
  • IP-based rate limiting is not equivalent to user-based rate limiting. Behind a corporate NAT there can be dozens of users sharing one IP. An aggressive per-IP policy in that context generates systematic false positives.
  • You can't assume the 429 reached the right attacker. Without logs, you don't know who you blocked. With logs, you have evidence to adjust.
  • Rate limiting doesn't replace authentication, authorization, or input validation. It's a last-line control against volumetric abuse, not a general security layer. Same logic applies to digital signatures: every control has its domain, none of them covers everything.

FAQ: common questions about rate limiting in Next.js

What rate limiting library do you recommend for Next.js?
@upstash/ratelimit is the most documented option for Next.js with Redis on the edge. express-rate-limit works fine if your backend is plain Node.js. But the library is the last step, not the first. If you haven't defined what asset you're protecting and what abuse you're expecting, any library gives you a false sense of control.

Rate limiting in Next.js middleware or in each Route Handler?
Depends on the granularity you need. Next.js middleware runs before the request reaches the route — useful for global or prefix-level protection. Route Handlers let you have different policies per endpoint. The most practical approach is usually: base policy in middleware, specific policy on sensitive endpoints (login, password recovery, forms with side effects).

Is IP enough as an identifier for rate limiting?
For amateur abuse, yes. For distributed attacks, no. If the asset you're protecting is critical — login, payments, endpoints that fire emails — combine IP with session or user identifier when it exists, and consider CAPTCHA as a progressive challenge instead of a hard block.

How do I handle rate limiting on Railway without Redis?
Railway lets you add Redis as an internal service. If you don't want that dependency, you can use an in-memory store for prototypes — but you have to accept that the limit won't be shared across instances. In production with multiple replicas, a shared store is necessary for the policy to be consistent.

Does infrastructure-layer rate limiting (CDN, WAF) replace the code-level one?
No, they're complementary. The infrastructure layer protects against raw volume and layer 7 DDoS. Application-level rate limiting enables semantic policies: by user, by action type, by cost of the operation. Both together are more robust than either one alone.

What do I do if rate limiting is blocking legitimate users?
First, log it. Second, check whether the problem is the threshold (too low) or the identifier (IP instead of user). Third, consider a progressive challenge mechanism: instead of blocking outright, ask for confirmation, CAPTCHA, or a short wait. Hard blocking should be the last resort, not the default response.


The policy before the dependency

The scene I opened with — an endpoint with zero abuse controls in a reference project — wasn't a missing-library problem. It was a design problem: nobody had defined what to protect, from what, and at what cost.

That order matters. If you pick the library first and then ask yourself what to configure, you end up with middleware that acts but has no policy. It acts in silence, blocks without documented criteria, and tells you nothing when it fails.

What I think is honest to recommend:

  1. List the assets that deserve protection — not the whole app, just the endpoints with real side effects.
  2. Define the type of abuse before choosing the control. They're different problems.
  3. Calculate the false positive cost for each asset before setting the threshold.
  4. Log it from day one. Without logs, the control is decoration.
  5. Use the library that fits the above, not the other way around.

Well-applied rate limiting is a real defensive control. Badly applied, it's a number in a config file that nobody will revisit until a legitimate user calls asking why they can't get in.

The question I'll leave you with is concrete: do you know today how many 429s your app returned in the last 24 hours, and to whom?


Primary source:


This article was originally published on juanchi.dev

Top comments (0)