Skip to content
advanced Phase 15 · Middleware and Edge Runtime

Throttling Abuse at the Edge

Limit request frequency before work reaches your origin.

45m
0 problems
Topic Progress 0%

Rejecting a Flood Before Any Handler Runs

A brute-force script is hitting POST /api/login twelve times a second. Every attempt costs you a body parse, a user lookup, a password hash comparison, and a failed-login row — work you perform for a client that was never going to succeed. The cheapest request to serve is the one you refuse in middleware, before routing, before the database, before authentication.

// proxy.ts (middleware.ts on Next.js ≤ 15)
import { NextResponse, type NextRequest } from 'next/server'

export const config = { matcher: '/api/(auth|login|signup|password-reset)/:path*' }

export default async function limiter(request: NextRequest) {
  const ip = clientIp(request)
  const key = `rl:${request.nextUrl.pathname}:${ip}`
  const { allowed, remaining, retryAfter } = await takeToken(key)   // store lives in the next chapter

  if (!allowed) {
    return NextResponse.json(
      { error: 'Too many requests', retryAfter },
      {
        status: 429,
        headers: {
          'Retry-After': String(retryAfter),
          'RateLimit-Remaining': '0',
        },
      }
    )
  }
  return NextResponse.next()
}

The matcher is doing quiet but essential work: rate limiting only the routes where abuse costs something, leaving pages and assets untouched — the scoping discipline from Writing Middleware. It also means the check runs before the route handler exists for the attacker: no framework routing, no Server Component tree, no database connection. A 429 with a tiny JSON body costs microseconds.

Key derivation is the part attackers test first. The obvious key is the client IP, and the obvious source is x-forwarded-for — a comma-separated list where the leftmost entry is whatever the client chose to send. If your edge does not strip or overwrite that header, an attacker rotates X-Forwarded-For values and gets a fresh allowance on every request, turning your limiter into decoration. Take the address from a hop you trust instead:

function clientIp(req: NextRequest): string {
  return (
    req.headers.get('cf-connecting-ip') ??              // behind Cloudflare
    req.headers.get('x-real-ip') ??                     // behind nginx
    req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() ??  // rightmost = added by your proxy
    'unknown'
  )
}

The rule: trust the rightmost value you appended, never the leftmost one the client supplied.

Now the failure everyone ships at least once. A const counts = new Map() at module scope works in development and is wrong in production, for three compounding reasons: serverless instances are disposable, so a cold start wipes the counter; a deployment behind N instances or regions lets each one grant the full limit, multiplying your allowance by N; and concurrent requests hitting different instances never see each other's increments. An in-memory counter is a demo. Accurate counting needs state shared by every instance handling the traffic — a stateful store such as Upstash Redis, reached over HTTP from the edge — which is the subject of the next chapter.

Two smaller decisions to make while you are here. Rate limit per route group, not globally: five failed logins per fifteen minutes and sixty searches per minute are different problems, and a single global bucket lets an attacker exhaust your login budget from the search endpoint by choosing a key of rl:${ip} alone — include the path (or the route group) in the key. And decide what happens when the limiter's own storage is unreachable: throwing turns a Redis outage into a site outage, while silently allowing is a security hole during exactly the moment an attacker would pick. Whatever you choose, log it — a 429 you can explain is a feature, and the response contract for explaining it is the last chapter.

Personalization assigned buckets in A/B Tests and Personalization at the Edge; the same file now decides whether a request deserves an answer at all.

Sliding Windows in Redis: Counting Accurately Across Edge Instances

Two edge instances, one client, a limit of ten requests per minute. Instance A counts to seven, instance B counts to seven — the user gets fourteen requests before either sees a 429. That gap is the entire reason rate limiting needs a shared, atomic store, and why the algorithm you put behind it matters as much as the limit itself.

Start with the algorithm most people reach for, and its flaw. A fixed window keeps one counter per period (rl:login:1.2.3.4:20261014T1432), resetting on a calendar boundary. A client may fire ten requests at 14:31:59 and ten more at 14:32:01 — twenty requests inside three seconds while every individual window stayed legal. The boundary, not the traffic, defines a fixed window's real limit: up to 2× your configured number.

A sliding window over sorted timestamps removes that artifact. Keep every hit's timestamp in a sorted set, drop everything older than the window on each access, and count what remains:

// Hand-rolled sliding window — one atomic round trip via a Lua script
const LUA = `
  local now = tonumber(ARGV[1])
  redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, now - tonumber(ARGV[2]))  -- drop expired hits
  local n   = redis.call('ZCARD', KEYS[1])
  if n < tonumber(ARGV[3]) then
    redis.call('ZADD', KEYS[1], now, ARGV[4])                         -- record this hit
    redis.call('PEXPIRE', KEYS[1], ARGV[2])
    n = n + 1
  end
  return n
`

Read and write must execute as one atomic step — separate ZCARD then ZADD calls from two instances interleave, both observe the same count, and both admit. Lua runs inside Redis's single-threaded execution, so no one else interleaves. Use the server's clock (TIME, or the managed service's) rather than each edge instance's clock: instances in different regions drift, and a skewed node grants extra window.

You rarely hand-write this. Upstash's client ships it as one call, and it is designed for exactly our constraint — the Node Redis driver speaks TCP via net, which the edge runtime does not provide; Upstash's client speaks HTTP through fetch, so it runs inside middleware unmodified:

import { Ratelimit } from '@upstash/ratelimit'
import { Redis } from '@upstash/redis'

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),                    // UPSTASH_REDIS_REST_URL / _TOKEN
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
})

const { success, limit, remaining, reset } = await ratelimit.limit(`login:${ip}`)
if (!success) {
  const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000))
  return NextResponse.json({ error: 'Too many requests' },
    { status: 429, headers: { 'Retry-After': String(retryAfter) } })
}

The trade-off you are accepting: one network round trip on every matched request (typically 5–20 ms), which is why the matcher must stay narrow — pages and static assets should never touch it. analytics: true records every decision server-side, so after a week you can see which key is actually consuming the budget instead of guessing at limits; and remaining / reset are exactly the numbers the response headers in the next chapter need.

And decide the outage behavior explicitly, because the default you ship under pressure is the one that fires during an incident:

Store status Fail open (allow) Fail closed (block)
Availability site keeps working every request 429/503
Security limiter is bypassed during an attack attacker cannot exploit the outage
Fit reads, search, public APIs login, signup, payment endpoints

Whichever you pick, emit a log line through event.waitUntil() so the next incident review shows why requests were allowed or refused. What the client sees when it is refused — statuses, headers, backoff — is the last piece.

429, Retry-After, and Rate-Limit Keys That Do Not Punish Innocent Users

Rate limiting is half enforcement, half contract: the client needs enough information to slow down correctly, or it will retry immediately, get blocked again, and — if it is a legitimate user behind a shared IP — churn.

The status code is 429 Too Many Requests (RFC 6585). It says precisely one thing: you caused this, and the remedy is waiting. That distinction matters against its neighbors:

Status Means Client should
429 your quota is exhausted wait, then retry — respect Retry-After
503 server/dependency is unavailable retry with backoff and jitter; no fault assigned
403 request is not permitted at all stop — retrying will not help

Retry-After carries delta-seconds (or an HTTP-date), and it is the mechanism that turns a rejection into recovery: a well-behaved client sleeps that long instead of hammering. Send it on every 429, computed from when the window actually frees up — not a constant:

const retryAfter = Math.max(1, Math.ceil((reset - Date.now()) / 1000))
return NextResponse.json(
  { error: 'Too many requests', retryAfter },
  { status: 429, headers: {
      'Retry-After': String(retryAfter),
      'RateLimit-Limit': '10',
      'RateLimit-Remaining': '0',
      'RateLimit-Reset': String(retryAfter),
  }}
)

RateLimit-Limit / RateLimit-Remaining / RateLimit-Reset follow the IETF RateLimit header fields draft; the older X-RateLimit-* variants remain common in the wild — pick one convention and document it, because SDKs read whichever they were built against. The client side of the contract is exponential backoff with jitter: delay = min(cap, base * 2 ** attempt) + random(). Without jitter, a fleet of clients that were blocked together retries together and recreates the thundering herd you just defended against.

Now the design question interviewers push on: what is the key? An IP alone fails in both directions, and you need to say why unprompted:

  • Collateral damage: offices, universities, and mobile carriers sit behind NAT/CGNAT with thousands of users sharing one address. Five failed logins per IP means five unrelated people lock each other out.
  • Ineffective against intent: a botnet rotates addresses constantly, so per-IP limits never trip; a targeted attack on one account stays under every per-IP threshold while the account is being sprayed from fifty hosts.

The answer is layered keys, sized to the abuse each endpoint actually sees:

Endpoint Key Limit (example)
POST /api/login IP and account identifier (separately) 5 / 15 min per IP; 10 / hour per account
POST /api/signup IP 3 / hour
GET /api/search session / user id 60 / minute
GraphQL, any operation user id (or IP when anonymous) 30 / minute

Two counters for login — one keyed by IP stops naive flooding, one keyed by the submitted account stops distributed spraying at a single victim. For authenticated routes, derive the key from a verified session, never from a field in the request body the attacker controls.

Keep a cheap second check inside the handler as defense in depth: matchers drift when routes are renamed, and internal calls bypass middleware entirely. And instrument it — log key, limit, outcome, and store status through event.waitUntil(); a limiter nobody can observe is a limiter nobody can tune.

The full interview shape, in one line each: key (who), algorithm (sliding window over timestamps), store (shared, HTTP-accessible from the edge), response (429 + Retry-After + rate headers), failure mode (fail open or closed, by endpoint), observability (log every decision). Deployed, this runs before your code — and the environment it runs in is configured in Managing Secrets Across Environments.

Quiz

1. A team keeps per-IP counters in a module-level `Map` in middleware. What is the production consequence?

Question 1 options

2. Your limit is 10 requests per minute in a fixed window. What is the worst-case traffic pattern that still passes the check?

Question 2 options

3. An edge request arrives with `X-Forwarded-For: 9.9.9.9, 203.0.113.5` where 203.0.113.5 was appended by your trusted proxy. Which value should key the rate limiter?

Question 3 options

4. A client exhausts a 10-per-10-seconds sliding window with 6.2 seconds remaining. What is the correct refusal?

Question 4 options

Flashcards

Question

Why enforce rate limits in middleware, and how do you keep the check cheap?

Answer

It runs before routing, authentication, body parsing, and any database work, so abusive requests cost a microseconds-long 429 instead of a full request cycle. Scope it with a matcher on the expensive route groups only (`/api/(auth|login|signup)/:path*`) so pages, assets, and static files never pay a network round trip.

Question

Fixed vs sliding window — the boundary problem and how a sliding window fixes it

Answer

Fixed window: one counter per period, resets on the boundary → up to 2× the limit across two windows (10 at :59 + 10 at :01). Sliding window: keep every hit's timestamp in a sorted set; on each request ZREMRANGEBYSCORE out the expired range, then count (ZCARD) and record (ZADD) — all in one atomic Lua script so concurrent instances cannot interleave the read and write. Use the store's clock, not each instance's.

Question

Why can't the counter live in process memory, and why Upstash specifically at the edge?

Answer

Instances are disposable (cold starts wipe counters) and horizontally distributed (N instances × full limit, regions blind to each other) — so the store must be shared. The Node Redis driver needs TCP (`net`), which the edge runtime lacks; Upstash's client speaks HTTP through `fetch`, so `Ratelimit.slidingWindow(10, '10 s')` runs inside middleware unchanged.

Question

The correct rejection contract: status, headers, and client behavior

Answer

HTTP 429 with `Retry-After` in delta-seconds computed from when the window actually frees, plus `RateLimit-Limit`, `RateLimit-Remaining`, `RateLimit-Reset`. 429 = your quota (wait), 503 = server unavailable, 403 = forbidden. Clients should honor Retry-After and otherwise use exponential backoff with jitter — without jitter, previously blocked clients retry in sync and create a thundering herd.

Question

How do you choose rate-limit keys, and what happens when the store is down?

Answer

Layer keys per endpoint: IP alone hurts NAT/CGNAT users (collateral) and misses botnets (rotating IPs), so use separate per-IP and per-account counters for login, per-session/user for authenticated routes — always from a verified session, never a client-supplied body field. Key the IP from the rightmost trusted hop (or cf-connecting-ip / x-real-ip), never the leftmost X-Forwarded-For. Decide fail-open (availability, for reads/search) vs fail-closed (security, for login/payment) explicitly, log every decision with event.waitUntil(), and keep a cheap in-handler check as defense in depth.

Revision Notes

Key Takeaways

  • 1. Enforce in middleware with a matcher limited to expensive routes: a 429 is emitted before routing, auth, and database work ever run
  • 2. Key derivation is the attack surface — trust the rightmost proxy-appended hop or a platform header; leftmost X-Forwarded-For is client-claimed and lets attackers mint identities
  • 3. Module-scope counters are a demo: cold starts reset them and N instances multiply the limit; use a shared store reachable from the edge over HTTP
  • 4. Fixed windows allow 2× bursts across the boundary; a sliding window over a ZSET with one atomic Lua script (store clock) counts honestly across instances
  • 5. Answer with 429 + Retry-After computed from the window (plus RateLimit headers), choose fail-open vs fail-closed per endpoint, and keep a second cheap check in the handler

Interview Tips

  • • "How would you rate limit an endpoint?" — walk the six-part shape: key, algorithm, store, response, failure mode, observability; naming sliding window and a shared HTTP store shows you have shipped one
  • • "Why not just a Map in middleware?" — instance-scoped memory: cold starts, N× multiplication, regional blindness; then say what you use instead and why the edge requires an HTTP-based Redis
  • • "How do you get the client IP safely?" — rightmost trusted hop / platform header, explain spoofing via leftmost X-Forwarded-For, and mention NAT collateral as the reason IP is never the only key
  • • "The limiter's Redis is down — what happens?" — state your choice per endpoint (fail open for reads, fail closed for login/payments), justify with availability vs security, and mention logging the decision with event.waitUntil plus an in-handler fallback check

Cheat Sheet

Throttling Abuse at the Edge

Where the check goes

export const config = { matcher: '/api/(auth|login|signup)/:path*' }   // only expensive routes
// in the handler: 429 before routing, DB, auth, body work

Client IP — trust the rightmost hop you control

req.headers.get('cf-connecting-ip') ??            // Cloudflare
req.headers.get('x-real-ip') ??                   // nginx
req.headers.get('x-forwarded-for')?.split(',').pop()?.trim() ??   // your proxy's entry
'unknown'

Leftmost X-Forwarded-For is client-claimed → spoofable identity rotation.

Never ship: const counts = new Map() — cold starts wipe it, N instances × limit each, regions can't see each other.

Algorithms

Fixed window Sliding window (ZSET)
Structure one counter per period every hit's timestamp
Weakness up to 2× limit across the boundary slightly more storage/writes
Reset calendar boundary oldest hit ages out
-- atomic read-modify-write (Redis is single-threaded → no interleaving)
ZREMRANGEBYSCORE key 0 now-window   -- drop expired
n = ZCARD key
if n < limit then ZADD key now id; PEXPIRE key window; n = n + 1 end
return n

Use the store's clock (TIME), not each edge instance's — regions drift.

Upstash-style client (edge-safe: HTTP via fetch, not the TCP net driver)

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(10, '10 s'),
  analytics: true,
})
const { success, remaining, reset } = await ratelimit.limit(`login:${ip}`)

Cost: one round trip (≈5–20 ms) per matched request → keep the matcher narrow.

Response contract

HTTP/1.1 429 Too Many Requests
Retry-After: 17
RateLimit-Limit: 10
RateLimit-Remaining: 0
RateLimit-Reset: 17
  • 429 = your quota (wait) · 503 = server unavailable (backoff) · 403 = forbidden (stop).
  • Client: exponential backoff with jitter — min(cap, base·2^attempt) + random().

Keys — layered, never IP alone

Endpoint Key Example limit
login IP and account (separate counters) 5/15 min per IP · 10/hour per account
signup IP 3/hour
search session / user id 60/min
GraphQL verified user id (IP if anonymous) 30/min

NAT/CGNAT shares IPs (collateral); botnets rotate IPs (ineffective) → per-IP and per-identity. Key from a verified session, never a body field.

Failure mode (decide explicitly)

Fail open Fail closed
Store down traffic flows all requests blocked
Fit reads/search/public APIs login/signup/payment

Defense in depth: keep a cheap in-handler check (matchers drift, internal calls bypass middleware). Log every decision with event.waitUntil().

Continue Learning