Back to Blog
critical SEVERITY8 min read

How Rate Limiting Vulnerabilities Happen in Next.js API Routes and How to Fix It

A critical rate limiting vulnerability in the `/api/claim` endpoint allowed attackers to exhaust the shared GitHub API quota by sending unlimited rapid requests. While the `/api/records` endpoint had proper throttling, the claim route only checked for GitHub rate limiting responses but implemented no per-user rate limiting, enabling abuse of the shared `REGISTRY_TOKEN` quota.

O
By Orbis AppSec
Published September 2, 2026Reviewed September 2, 2026

Answer Summary

This is a rate limiting bypass vulnerability (CWE-770: Allocation of Resources Without Limits or Throttling) in a Next.js API route. The `/api/claim` endpoint in `app/api/claim/route.js` lacked per-user rate limiting, allowing attackers to send unlimited rapid POST requests and exhaust the shared GitHub API quota. The fix implements session-based rate limiting using `createRateLimiter()` with a 10-minute window allowing maximum 12 claims per user, matching the protection already present on the `/api/records` endpoint.

Vulnerability at a Glance

cweCWE-770 (Allocation of Resources Without Limits or Throttling)
fixAdded `createRateLimiter()` with 12 requests per 10-minute window per user session
riskAPI quota exhaustion, denial of service, rapid resource claiming
languageJavaScript (Next.js)
root causeNo session-based throttling on `/api/claim` despite consuming shared GitHub API token
vulnerabilityMissing per-user rate limiting on API endpoint

Introduction

In a Next.js application handling name registry claims, we discovered a critical rate limiting vulnerability in app/api/claim/route.js. The /api/claim endpoint processes POST requests to claim usernames, consuming a shared GitHub API token (REGISTRY_TOKEN) for each attempt. However, unlike its sibling endpoint /api/records, this route had no per-user rate limiting—only a basic busy response that reacted to GitHub's own rate limiting.

This meant an attacker with valid session credentials could send hundreds of rapid POST requests, exhausting the shared API quota and potentially claiming multiple names before atomic checks could prevent it. The vulnerability was particularly dangerous because the endpoint was publicly accessible and the application explicitly acknowledged in comments that other endpoints needed rate limiting for the same reason—but this protection was never added to /api/claim.

The Vulnerability Explained

Let's examine the vulnerable code in app/api/claim/route.js before the fix:

import { sessionFromRequest } from '../../../lib/session.js';
import { evaluateClaim } from '../../../lib/claim.js';
import { putRecord } from '../../../lib/registry.js';
import { getOwnerIndex, putOwnerIndex } from '../../../lib/owners.js';

const TOKEN = () => process.env.REGISTRY_TOKEN;

const BUSY_RESPONSE = () =>
  Response.json({ error: 'busy', retryInMs: 4000 }, { status: 503, headers: { 'Retry-After': '4' } });

export async function POST(request) {
  const session = await sessionFromRequest(request);
  const { name } = await request.json();

  let ownerIndex = null;
  if (session?.login) {
    try {
      ownerIndex = await getOwnerIndex(session.login, { token: TOKEN() });
    } catch {
      return BUSY_RESPONSE();
    }
    // ... claim processing continues
  }
}

The problem is on line 17: after extracting the session, the code immediately calls getOwnerIndex(session.login, { token: TOKEN() }) without any rate limiting check. Every request from the same user consumes the shared REGISTRY_TOKEN quota.

The only protection was the BUSY_RESPONSE() catch block, which returned a 503 error if GitHub itself rate-limited the token. But this is reactive, not proactive—by the time GitHub throttles you, an attacker has already sent dozens or hundreds of requests, exhausting your quota and potentially disrupting service for legitimate users.

How Could This Be Exploited?

An attacker with a valid GitHub session could:

  1. Script rapid POST requests to /api/claim with different or identical name payloads
  2. Exhaust the shared REGISTRY_TOKEN quota across all users, causing the entire service to become unavailable
  3. Attempt to claim multiple names in rapid succession before atomic checks on the GitHub side could prevent duplicates
  4. Trigger denial of service for legitimate users who would receive 503 errors

Here's what an attack script might look like:

// Attacker script
const session_cookie = "session=valid_github_token";
for (let i = 0; i < 1000; i++) {
  fetch('https://victim-app.com/api/claim', {
    method: 'POST',
    headers: { 
      'Content-Type': 'application/json',
      'Cookie': session_cookie 
    },
    body: JSON.stringify({ name: `claimed-name-${i}` })
  });
}

Without per-user rate limiting, all 1000 requests would be processed sequentially, each consuming GitHub API quota. The application would only stop when GitHub's own rate limiting kicked in—far too late.

Real-World Impact

For this specific name registry application:

  • Service disruption: Legitimate users couldn't claim names because the quota was exhausted
  • Unfair resource allocation: Attackers could claim multiple desirable names before rate limits applied
  • Operational costs: Increased GitHub API usage, potential need for higher-tier API access
  • Inconsistent security posture: The /api/records endpoint had proper rate limiting (as noted in the fix comments), but /api/claim didn't, creating a security gap

The Fix

The security patch adds per-user rate limiting to the /api/claim endpoint, matching the protection already present on /api/records. Here's the before-and-after comparison:

Before (Vulnerable):

export async function POST(request) {
  const session = await sessionFromRequest(request);
  const { name } = await request.json();

  let ownerIndex = null;
  if (session?.login) {
    try {
      ownerIndex = await getOwnerIndex(session.login, { token: TOKEN() });
    } catch {
      return BUSY_RESPONSE();
    }

After (Fixed):

import { createRateLimiter } from '../../../lib/throttle.js';

const CLAIM_WINDOW_MS = 10 * 60 * 1000;
const CLAIM_MAX = 12;
const takeClaim = createRateLimiter({ windowMs: CLAIM_WINDOW_MS, max: CLAIM_MAX });

export async function POST(request) {
  const session = await sessionFromRequest(request);
  const { name } = await request.json();

  let ownerIndex = null;
  if (session?.login) {
    const budget = takeClaim(session.login.toLowerCase());
    if (!budget.ok) {
      const seconds = Math.ceil(budget.retryAfterMs / 1000);
      return Response.json(
        { error: 'rate_limited', retryInMs: budget.retryAfterMs },
        { status: 429, headers: { 'Retry-After': String(seconds) } },
      );
    }
    try {
      ownerIndex = await getOwnerIndex(session.login, { token: TOKEN() });
    } catch {
      return BUSY_RESPONSE();

Key Changes:

  1. Import rate limiter (line 5): Added createRateLimiter from the existing throttle library
  2. Configure limits (lines 7-9): Set a 10-minute window with maximum 12 claims per user—matching the /api/records endpoint's rationale
  3. Check budget before processing (lines 18-25): Call takeClaim(session.login.toLowerCase()) and return HTTP 429 if the user has exceeded their quota
  4. Proper HTTP response: Return status: 429 (Too Many Requests) with Retry-After header indicating when the user can retry

The fix also includes an explanatory comment:

// Same rationale as /api/records: claiming spends the same shared
// REGISTRY_TOKEN quota (an owner-index read plus a record write per
// attempt), so a looped or leaned-on button must be capped per account here
// too, not just on the edit endpoint.

This comment is crucial—it documents why rate limiting is necessary, referencing the shared resource (REGISTRY_TOKEN) and the specific operations that consume quota.

Frontend Update

The fix also updates app/claim-form.jsx to handle the new rate limiting response:

ineligible_repos: 'Your GitHub account needs at least one pu

While the diff is truncated, this likely adds a user-friendly message for the rate_limited error case, ensuring users understand why their request was rejected.

Prevention & Best Practices

1. Apply Rate Limiting to All State-Changing Endpoints

Any endpoint that:
- Consumes external API quota
- Modifies database state
- Sends emails or notifications
- Performs expensive computations

...should have per-user or per-IP rate limiting. Don't assume that downstream rate limiting (like GitHub's) is sufficient protection.

2. Use Consistent Rate Limiting Policies

In this codebase, /api/records already had rate limiting, but /api/claim didn't. Maintain a consistent security posture:

// Define rate limiting policies centrally
const API_RATE_LIMITS = {
  claim: { windowMs: 10 * 60 * 1000, max: 12 },
  records: { windowMs: 10 * 60 * 1000, max: 12 },
  search: { windowMs: 1 * 60 * 1000, max: 60 },
};

3. Implement Defense in Depth

Combine multiple rate limiting strategies:
- Per-user limits: Based on session or API key
- Per-IP limits: For anonymous endpoints
- Global limits: Protect against distributed attacks
- Adaptive throttling: Increase restrictions during attack patterns

4. Return Proper HTTP Status Codes

Always use HTTP 429 (Too Many Requests) for rate limiting, not 503 (Service Unavailable) or 403 (Forbidden). Include the Retry-After header to inform clients when they can retry:

return Response.json(
  { error: 'rate_limited', retryInMs: budget.retryAfterMs },
  { status: 429, headers: { 'Retry-After': String(seconds) } },
);

5. Use Established Libraries

Don't roll your own rate limiting from scratch. Use proven libraries:
- Node.js: express-rate-limit, rate-limiter-flexible
- Python: flask-limiter, slowapi
- Go: golang.org/x/time/rate
- Ruby: rack-attack

6. Monitor and Alert

Track rate limiting metrics:
- Number of requests blocked per endpoint
- Users hitting limits repeatedly
- Sudden spikes in rate limit violations

Set up alerts for abnormal patterns that might indicate an attack.

7. Document Rate Limits

Make rate limits visible to API consumers:
- Document limits in API documentation
- Include current usage in response headers (X-RateLimit-Remaining)
- Provide clear error messages explaining the limit

Key Takeaways

  • The /api/claim endpoint consumed shared GitHub API quota but had no per-user throttling, allowing unlimited rapid requests from authenticated users
  • Rate limiting must be proactive, not reactive—waiting for GitHub's rate limiting to kick in meant attackers could exhaust quota before protection applied
  • The createRateLimiter() function with 12 requests per 10-minute window now prevents individual users from monopolizing the shared REGISTRY_TOKEN
  • Consistency matters: The /api/records endpoint already had identical rate limiting, but the vulnerability existed because /api/claim was overlooked
  • Proper HTTP semantics improve security: Returning 429 with Retry-After headers allows legitimate clients to back off gracefully while blocking abuse

How Orbis AppSec Detected This

  • Source: Authenticated POST requests to /api/claim endpoint with session cookies
  • Sink: getOwnerIndex(session.login, { token: TOKEN() }) call in app/api/claim/route.js:25 consuming shared GitHub API token without rate limiting
  • Missing control: No per-user throttling before the getOwnerIndex call, despite the endpoint consuming the same shared REGISTRY_TOKEN quota as the protected /api/records endpoint
  • CWE: CWE-770 (Allocation of Resources Without Limits or Throttling)
  • Fix: Added createRateLimiter() with 10-minute window and 12-request maximum per user session, returning HTTP 429 when exceeded

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

This rate limiting vulnerability demonstrates why security controls must be applied consistently across all endpoints that consume shared resources. While the application developers had correctly implemented rate limiting on /api/records, they overlooked the /api/claim endpoint, creating an exploitable gap.

The fix is straightforward—adding session-based throttling with clear limits and proper HTTP responses—but the lesson is broader: security is not just about protecting individual functions, but ensuring consistent protection across your entire API surface. When you implement a security control in one place, audit similar endpoints to ensure they have equivalent protection.

By adding the createRateLimiter() check with a 10-minute window and 12-request maximum, the application now protects the shared GitHub API quota from abuse while still allowing legitimate users to claim names at a reasonable rate. The inclusion of explanatory comments and proper error handling makes the code maintainable and the security boundary clear to future developers.

References

Frequently Asked Questions

What is a rate limiting vulnerability?

A rate limiting vulnerability occurs when an API endpoint fails to restrict the number of requests a user can make within a time window, allowing attackers to exhaust resources, bypass security controls, or cause denial of service through automated high-volume requests.

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

Implement per-user or per-IP rate limiting using libraries like `rate-limiter-flexible` or custom solutions. Track request counts per session/identifier in a time window (e.g., 12 requests per 10 minutes), return HTTP 429 status when limits are exceeded, and include `Retry-After` headers to inform clients when they can retry.

What CWE is rate limiting vulnerability?

Rate limiting vulnerabilities are classified as CWE-770 (Allocation of Resources Without Limits or Throttling), which describes the failure to properly control resource consumption rates, potentially leading to denial of service or resource exhaustion.

Is basic busy response enough to prevent rate limiting attacks?

No. A busy response that only reacts to upstream rate limiting (like GitHub's API limits) doesn't protect your own resources. Attackers can still exhaust your shared API quota or claim multiple resources before the upstream service throttles you. You need proactive per-user rate limiting at your endpoint level.

Can static analysis detect rate limiting vulnerabilities?

Yes, advanced static analysis tools can detect missing rate limiting by analyzing API endpoint patterns, identifying endpoints that consume external resources or modify state, and checking whether rate limiting middleware or logic is applied. Tools like Orbis AppSec use multi-agent AI to flag endpoints lacking proper throttling controls.

View the Security Fix

Check out the pull request that fixed this vulnerability

View PR #28

Related Articles

critical

How Missing Authentication on DELETE Endpoints Happens in Node.js Express and How to Fix It

A critical authentication bypass vulnerability was discovered in the skill-cabinet server where the DELETE /api/skills/:id endpoint allowed any unauthenticated user to delete arbitrary skills from the filesystem. The fix implements loopback origin validation to ensure only requests from localhost can perform destructive operations, while also consolidating delete functionality into a single, protected endpoint.

high

How Cross-Site Request Forgery (CSRF) happens in Express.js and how to fix it

A semgrep audit flagged `devboard/server/index.js` for lacking any CSRF middleware, meaning every state-changing route (`POST`, `PUT`, `DELETE` under `/api/*`) could be triggered by a forged cross-origin request riding on a victim's session cookie. The fix wires in `cookie-parser` and `csurf` right after body parsing, so every mutating request now requires a valid, per-session CSRF token before it reaches route handlers.

critical

How missing authorization and code injection happen in Mindustry JavaScript mods and how to fix it

A `TapEvent` handler in `scripts/CommandBlock.js` exposed a full administrative command palette — including a `run-javascript` command that piped player-supplied text straight into `new Function(text)()` — behind nothing more than a team-membership check. Any player who happened to share a team with the block could execute arbitrary JavaScript (and, through Rhino's Java bridge, arbitrary host code) inside the game runtime. The fix adds an explicit `if (!e.player.admin) return;` guard at the top

critical

How Unauthenticated API Exposure Happens in Node.js Koa Routers and How to Fix It

The `/api/adapters` and `/api/list` endpoints in the OneBots framework were registered before authentication middleware, making them publicly accessible to unauthenticated attackers. This critical vulnerability allowed anyone to enumerate all configured adapters, accounts, and sensitive metadata with a simple GET request. The fix ensures these endpoints are protected by the existing auth middleware by correcting route registration order.

high

How Unauthorized SSH Command Execution Happens in Go and How to Fix It

A high-severity vulnerability in `golang.org/x/crypto/ssh` (CVE-2026-39828) allowed attackers to execute unauthorized commands by exploiting discarded SSH permissions. The fix involved upgrading `golang.org/x/crypto` from v0.51.0 to v0.52.0 in `go.mod`, closing an authentication bypass that could be triggered remotely in any Go service using the SSH package.

critical

How Supply Chain Attacks Happen via pnpm Workspace Configuration and How to Fix Them

A pnpm workspace configuration was missing the `minimumReleaseAge` setting, leaving the project vulnerable to supply chain attacks from newly published malicious or compromised npm packages. By adding `minimumReleaseAge: 10080` (seven days in minutes), the fix ensures that only packages that have survived community scrutiny for at least a week are resolved during installation. This defensive hardening is especially critical for web applications where a compromised dependency could introduce XSS,