Back to Blog
critical SEVERITY8 min read

How Missing Rate Limiting Happens in Next.js API Routes and How to Fix It

Three public API endpoints in a Next.js application — `/api/send-review`, `/api/contact`, and `/api/auth` — were deployed without any server-side rate limiting, allowing attackers to flood them with unlimited requests. The `/api/send-review` and `/api/contact` endpoints were especially dangerous because every request triggered an outbound email via Gmail SMTP, making them prime targets for email bombing and quota exhaustion. The fix introduces a lightweight in-memory rate limiter capping each IP

O
By Orbis AppSec
Published August 4, 2026Reviewed August 4, 2026

Answer Summary

This vulnerability is a Missing Rate Limiting flaw (CWE-770: Allocation of Resources Without Limits or Throttling) in a Next.js application's `app/api/send-review/route.js` API route. Because the `POST` handler called `nodemailer` to send email on every request with no frequency checks, an attacker could send unlimited POST requests to exhaust the application's Gmail SMTP quota and flood recipients with spam. The fix adds an in-memory `isRateLimited()` function that tracks request counts per IP address, enforcing a hard limit of 5 requests per IP per 15-minute sliding window and returning HTTP 429 when the limit is exceeded.

Vulnerability at a Glance

cweCWE-770
fixAdded an in-memory isRateLimited() function enforcing 5 requests per IP per 15-minute window before any email logic executes
riskUnlimited POST requests trigger outbound email on every call, enabling Gmail SMTP quota exhaustion and email flooding
languageJavaScript (Next.js)
root causeThe POST handler in app/api/send-review/route.js called nodemailer unconditionally with no per-IP request frequency check
vulnerabilityMissing Rate Limiting / Resource Exhaustion

The Problem With Unlimited Email Triggers

The app/api/send-review/route.js file in this Next.js application handles one job: accept a POST request and send an email via Gmail SMTP using nodemailer. It's a common, useful pattern — but in its original form, there was nothing stopping anyone on the internet from calling that endpoint thousands of times in rapid succession. Every single call would dutifully fire off an outbound email, draining the application's Gmail sending quota and potentially flooding recipients' inboxes.

This isn't a hypothetical edge case. Public-facing API routes that trigger email sends are among the most commonly abused endpoints on the web, and the fix is straightforward once you know what to look for.


The Vulnerability Explained

What the original code did

Here's the original POST handler, stripped to its essentials:

// app/api/send-review/route.js (before fix)
export async function POST(request) {
  const { name, email, review, rating, to } = await request.json();

  try {
    // ... nodemailer transporter setup and sendMail call
  }
}

The handler immediately destructures the request body and proceeds to send an email. There is no check on who is calling, no check on how often they are calling, and no ceiling on total calls. The entire email-sending pipeline runs unconditionally for every valid POST request that reaches the server.

Why this is dangerous for this specific endpoint

The /api/send-review endpoint (and its sibling /api/contact) use nodemailer to relay mail through a Gmail SMTP account. Gmail's free-tier sending quota sits at roughly 500 messages per day for standard accounts (2,000 for Workspace). An attacker running even a modest script — say, 50 requests per second — could exhaust that quota in under 10 seconds, causing:

  1. Email bombing of the recipient address — the to field is taken directly from the request body, meaning an attacker can target any address.
  2. Gmail account suspension — exceeding quota triggers temporary or permanent sending bans.
  3. Denial of service for legitimate users — once the quota is gone, real review submission emails fail silently.

The /api/auth endpoint's weaker protection

The PR description notes that /api/auth relies on Cloudflare Turnstile CAPTCHA as its only abuse-prevention layer. While CAPTCHA adds friction, it is not a rate limit. Automated CAPTCHA-solving services cost fractions of a cent per solve, and a single valid CAPTCHA token can sometimes be replayed within its validity window. Without a server-side fallback, CAPTCHA bypass equals unlimited access.

Attack scenario

POST /api/send-review HTTP/1.1
Host: target-app.com
Content-Type: application/json

{"name":"Attacker","email":"attacker@evil.com","review":"spam","rating":5,"to":"victim@company.com"}

A script repeating this request in a loop requires no authentication, no API key, and no special knowledge of the application. The attacker needs only network access and the endpoint URL — both of which are public by definition.


The Fix

What changed in app/api/send-review/route.js

The fix adds 20 lines before the existing handler logic: a Map-based in-memory rate limiter and a guard clause at the top of the POST function.

Before:

export async function POST(request) {
  const { name, email, review, rating, to } = await request.json();
  // email sending proceeds immediately

After:

// Simple in-memory rate limiter: max 5 requests per IP per 15 minutes
const rateLimit = new Map();
const LIMIT = 5;
const WINDOW_MS = 15 * 60 * 1000;

function isRateLimited(ip) {
  const now = Date.now();
  const entry = rateLimit.get(ip) || { count: 0, start: now };
  if (now - entry.start > WINDOW_MS) {
    rateLimit.set(ip, { count: 1, start: now });
    return false;
  }
  if (entry.count >= LIMIT) return true;
  entry.count++;
  rateLimit.set(ip, entry);
  return false;
}

export async function POST(request) {
  const ip =
    request.headers.get('x-forwarded-for')?.split(',')[0]?.trim() || 'unknown';
  if (isRateLimited(ip)) {
    return NextResponse.json(
      { success: false, error: 'Too many requests' },
      { status: 429 }
    );
  }
  const { name, email, review, rating, to } = await request.json();
  // email sending only reached after rate-limit check passes

How isRateLimited works

The function uses a sliding-window counter stored in a module-level Map:

Step Logic
1. Look up the IP Retrieve the existing { count, start } entry, or create a fresh one.
2. Check window expiry If more than 15 minutes have elapsed since start, reset the counter to 1 and allow the request.
3. Check the limit If count >= 5, return true (rate limited).
4. Increment Otherwise, increment count, save the entry, and return false (allowed).

The IP is extracted from the x-forwarded-for header (taking only the first, leftmost value to avoid header spoofing by downstream proxies) with a fallback of 'unknown' for requests that arrive without the header.

What this concretely prevents

With the fix in place:
- A single IP can trigger at most 5 outbound emails every 15 minutes — well within normal human usage.
- The Gmail quota can no longer be exhausted by a single attacker in a single burst.
- The to field in the request body still needs input validation, but the blast radius of any abuse is now tightly bounded.


Prevention & Best Practices

1. Always rate-limit endpoints that trigger side effects

Any route that sends email, SMS, pushes to a queue, or writes to a database should have rate limiting applied before the side-effectful operation. The check must happen server-side — client-side throttling is trivially bypassed.

2. Choose the right rate-limiting store for your architecture

Deployment Recommended store
Single Next.js instance In-memory Map (as used here)
Multiple instances / serverless Redis with ioredis or Upstash
Edge runtime Cloudflare KV or Deno KV

The in-memory approach in this fix is correct for a single-instance deployment but will not share state across multiple Node.js processes or serverless function invocations. If this application scales horizontally, migrate to a shared store.

3. Return HTTP 429 with a Retry-After header

The fix correctly returns status 429. Consider adding a Retry-After header to help legitimate clients back off gracefully:

return NextResponse.json(
  { success: false, error: 'Too many requests' },
  {
    status: 429,
    headers: { 'Retry-After': '900' }, // 15 minutes in seconds
  }
);

4. Validate the to field

The to address is taken directly from the request body and passed to nodemailer. A follow-up hardening step should validate that to matches an expected domain or a fixed list of allowed recipient addresses, preventing the endpoint from being used as an open email relay.

5. Layer defenses — don't rely on CAPTCHA alone

CAPTCHA (Turnstile, reCAPTCHA, hCaptcha) is a UX-level control, not a security boundary. Treat it as a first filter and always back it up with server-side rate limiting.

6. Use established middleware libraries

For production Next.js applications, consider next-rate-limit or @upstash/ratelimit rather than hand-rolled Map logic. These libraries handle edge cases like clock skew, distributed deployments, and header normalization.

Relevant standards

  • OWASP API Security Top 10 — API4:2023: Unrestricted Resource Consumption
  • CWE-770: Allocation of Resources Without Limits or Throttling
  • OWASP Cheat Sheet: Denial of Service Cheat Sheet

Key Takeaways

  • nodemailer calls in a Next.js route handler are a rate-limiting red flag — any unauthenticated endpoint that sends email must have per-IP throttling before the send logic runs.
  • The isRateLimited() guard must come before await request.json() — parsing the body is cheap, but it's good practice to reject over-limit requests as early as possible to minimize processing.
  • x-forwarded-for must be split on commas — the header can contain a chain of IPs; always take [0] (the original client) to avoid trivial bypass via header appending.
  • CAPTCHA on /api/auth is not a substitute for rate limiting — the PR description explicitly identifies this gap; a bypass of Turnstile would leave the auth endpoint fully unprotected.
  • In-memory rate limiters don't survive process restarts or scale horizontally — the fix is appropriate for the current deployment model but should be revisited if the application moves to a serverless or multi-instance architecture.

How Orbis AppSec Detected This

  • Source: Unauthenticated HTTP POST body fields (name, email, review, rating, to) received by the POST handler in app/api/send-review/route.js
  • Sink: nodemailer sendMail() call triggered unconditionally on every request, with no preceding frequency check
  • Missing control: No per-IP request rate limit, no request count tracking, no HTTP 429 response path — the email-sending side effect was reachable an unlimited number of times
  • CWE: CWE-770 — Allocation of Resources Without Limits or Throttling
  • Fix: Added isRateLimited(ip) guard at the top of the POST handler, returning HTTP 429 after 5 requests per IP within a 15-minute window

Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.


Conclusion

Missing rate limiting on email-triggering API routes is one of those vulnerabilities that looks harmless in isolation — after all, the endpoint is supposed to send emails — but becomes a serious operational and security liability the moment a bad actor discovers it. The original app/api/send-review/route.js handed attackers a direct line to the application's Gmail SMTP account with no friction whatsoever.

The fix is elegant in its simplicity: 20 lines of pure JavaScript, no new dependencies, and a hard cap of 5 requests per IP per 15 minutes. It doesn't change the behavior for any legitimate user (who will rarely submit more than one or two reviews in a quarter-hour), but it makes email bombing attacks economically and technically unviable.

The broader lesson is architectural: whenever you write a route handler that triggers an outbound side effect — email, SMS, webhook, database write — ask yourself what happens if this is called 10,000 times in a minute? If the answer is "bad things," add rate limiting before you ship.


References

Frequently Asked Questions

What is missing rate limiting in a web API?

Missing rate limiting means an API endpoint places no cap on how many requests a single client can make in a given time window, allowing one actor to send unlimited requests and abuse any side effects (such as sending emails) that the endpoint triggers.

How do you prevent missing rate limiting in Next.js?

Add a rate-limiting check at the very top of your route handler — before any business logic — using either an in-memory store (Map-based sliding window) for single-instance deployments or a distributed store like Redis for multi-instance setups. Return HTTP 429 when the limit is exceeded.

What CWE is missing rate limiting?

CWE-770: Allocation of Resources Without Limits or Throttling. OWASP also lists this under API4:2023 Unrestricted Resource Consumption in its API Security Top 10.

Is Turnstile CAPTCHA enough to prevent abuse of these endpoints?

No. CAPTCHA is a client-side friction mechanism that can be bypassed by automated solvers or by replaying a valid CAPTCHA token. Server-side rate limiting provides a deterministic, enforceable backstop that CAPTCHA alone cannot guarantee.

Can static analysis detect missing rate limiting?

Yes. Tools like Semgrep can flag Next.js route handlers that call email-sending libraries (e.g., nodemailer) without a preceding rate-limit guard. Orbis AppSec's multi-agent AI scanner detected this exact pattern in the production codebase.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #74

Related Articles

critical

How unlimited batch API calls happen in React JSX and how to fix it

A missing batch size limit in `BatchModeRunner.jsx` allowed users to trigger unlimited LLM API calls by pasting thousands of items into the batch input field. This could exhaust shared API quotas in organizational settings where a single API key is distributed across multiple users. The fix introduces a hard cap of 25 items (`MAX_BATCH_SIZE = 25`) enforced directly in the `canRun()` validation function.

medium

Defending Against Rate Limit Bypass: Securing Express Applications from IP Spoofing

A critical rate limiting vulnerability in an Express.js application allowed attackers to bypass API throttling through IP rotation and header manipulation. This fix demonstrates how improperly configured rate limiters can be circumvented through proxy networks, VPNs, and forged X-Forwarded-For headers, potentially enabling brute force attacks, credential stuffing, and resource exhaustion.

critical

Node-tar Path Traversal: How a Hardlink Bypass Threatened File Systems

A medium-severity vulnerability (CVE-2026-24842) in node-tar allowed attackers to create arbitrary files outside intended directories by exploiting a flaw in hardlink security checks. Combined with missing rate limiting controls, this vulnerability exposed applications to both path traversal attacks and denial-of-service through unlimited automated requests. Here's what happened and how to protect your applications.

critical

How unvalidated URL input handling happens in SvelteKit with Tauri and how to fix it

A critical vulnerability in `src/routes/+page.svelte` allowed attackers to supply arbitrary URLs—including `http://` and local file paths—through query parameters and drag-drop events, which were then fetched without validation. The fix restricts input to HTTPS-only URLs and removes the dangerous local file fetch path entirely, eliminating both SSRF and local file disclosure attack vectors.

critical

How SQL injection happens in Node.js string interpolation and how to fix it

A critical SQL injection vulnerability was discovered in the `getScript()` method of `src/core/statistics.js`, where the `metadata_id` variable was directly interpolated into DELETE and UPDATE SQL statements without any validation. An attacker controlling this parameter could inject malicious SQL payloads to delete entire tables or exfiltrate sensitive data. The fix implements strict input validation using `parseInt()` and regex patterns to ensure only safe values reach the database queries.

critical

How SQL Injection happens in Node.js MySQL queries and how to fix it

A critical SQL injection vulnerability was discovered in `divisible_asset.js` where `message_index` and `output_index` values from external payment data were directly interpolated into SQL queries without proper escaping. This fix applies `conn.escape()` to these parameters, preventing attackers from manipulating database queries through crafted payment elements.