Rate-Limit / Abuse Guard
Drop-in rate limiting for Next.js + Supabase that doesn't punish families.
Most rate limiters key by IP — which throttles everyone behind one NAT together,
so a household or office gets locked out as a group. This one keys an
authenticated request by its userId (each person gets their own bucket) and
only falls back to the IP for truly anonymous traffic. Token bucket, zero
dependencies, shipped .d.ts types, memory bounded by an LRU key cap.
npm i airlock-ratelimit
Route handler (App Router)
import { withRateLimit } from 'airlock-ratelimit/next'
export const POST = withRateLimit(
async (req) => {
// ... your handler
return Response.json({ ok: true })
},
{
limit: 30, // per identified user, per window
window: 10_000, // ms
ipLimit: 100, // higher ceiling for anonymous traffic per IP
trustProxy: true, // REQUIRED: your proxy appends x-forwarded-for (Vercel & most
// ingresses). Behind nginx use { ipHeader: 'x-real-ip' }; behind
// Cloudflare { ipHeader: 'cf-connecting-ip' }; with no proxy at
// all { trustProxy: false }. See "declare your proxy topology".
identify: (req) => getVerifiedUserId(req), // server-verified id, or null if anon
}
)
Over the limit → a 429 with Retry-After and rate-limit headers (both the
de-facto X-RateLimit-* and the IETF-draft RateLimit-*). Under it → your handler
runs, with the same headers attached.
Custom 429 (redirect, your own JSON, extra headers)
Pass onLimit to return your own response when the limit is hit — the rate-limit
headers are still added where you leave them unset:
export const POST = withRateLimit(handler, {
limit: 30,
trustProxy: true,
onLimit: (r, req) => Response.json(
{ message: `Devagar! Tente de novo em ${r.retryAfter}s.` },
{ status: 429 },
),
})
Just the limiter
import { rateLimit } from 'airlock-ratelimit'
import { getClientIp } from 'airlock-ratelimit/next'
const rl = rateLimit({ limit: 30, window: 10_000 })
// Derive the IP through getClientIp with your topology declared — do NOT pass a
// raw header. `req.headers.get('x-forwarded-for')` is a comma-separated list
// whose leftmost hops the caller chose; the limiter refuses to key on a value
// that is not an IP, so passing one lands every request on the unkeyable path.
const ip = getClientIp(req, { trustProxy: true })
const r = rl.check({ ip, userId }) // { ok, scope, limit, remaining, retryAfter, resetMs }
if (!r.ok) return tooMany(r)
Why the family thing matters
rateLimit({ limit: 2 }), one IP, two people:
rl.check({ ip, userId: 'mom' }) // ok
rl.check({ ip, userId: 'mom' }) // ok
rl.check({ ip, userId: 'mom' }) // 429 — mom hit HER limit
rl.check({ ip, userId: 'dad' }) // ok — dad is untouched
Key by user and a chatty app stops locking out whole households. (We learned this the hard way — 30 requests / 10s per IP was blocking families on one connection.)
Where this does NOT help: anonymous traffic. A request with no userId has
nothing to key on but the IP, so the household shares one bucket again — and
that includes login, signup and password reset, which is exactly where you most
want a limit. ipLimit defaults to limit for anonymous traffic (it only
defaults to limit × 10 when alsoLimitIp is on), so set ipLimit
explicitly on those routes: roughly limit × (people/devices per IP). The
headline is about authenticated traffic; be deliberate about the rest.
API
rateLimit({ limit?, window?, ipLimit?, alsoLimitIp?, maxKeys?, onUnkeyable?, now? })→{ check, reset, sweep }check({ ip, userId })→{ ok, scope: 'user'|'ip'|'none', limit, remaining, retryAfter, resetMs }reset({ ip, userId })— clear a bucket. Ids and IPs are canonicalized exactly ascheckcanonicalizes them, soreset({ userId: 0 })and a padded id both hit the right bucket.withRateLimit(handler, opts)— wrap a Next.js route handler. Takes everythingrateLimittakes, plusidentify,onLimit,ipHeader,trustProxy. Passing a pre-builtlimiterand a construction option throws, because the limiter already has its own.getClientIp(req, opts?)— trusted client IP, canonicalized (IPv6 spellings like2001:db8::1/[2001:DB8::1]fold to one key, so they can't be rotated to dodge the limit). You must say which header to trust:opts.trustProxy: trueuses the last (proxy-appended)x-forwarded-forhop,opts.ipHeaderpins a single overwritten header (e.g.cf-connecting-ip),opts.trustProxy: falsederives none. With neither, it returnsnull— see "you must declare your proxy topology" below for the two measured bypasses that is there to prevent.
Security & limits — read before shipping
No key at all → the request is DENIED (429) by default. When there is no
userIdand no usable IP, the limiter cannot tell callers apart, so it fails closed. This state is reachable by accident, not just in theory:trustProxy: falseon anonymous traffic, anidentifythat returnsnullor throws, or a typo in theipHeaderyou pinned. Previously this returnedok: trueforever — a silent no-op where the route looked protected and wasn't. SetonUnkeyable: 'allow'to opt back into open behavior; it must be your declared choice, never a default you didn't know about.identifymust return a server-verified id (from a validated session or JWT). If you return a value read straight off a client header/param, an attacker forges an endless stream of ids and slips the limit. When you can't fully trust the id, setalsoLimitIp: trueso the per-IP ceiling still applies to authenticated requests (defense in depth).With
alsoLimitIp, sizeipLimitgenerously. Every authenticated request then also spends an IP token, so a too-lowipLimitlets one heavy user drain the shared ceiling and lock out the household — the very thing this limiter avoids. It defaults tolimit × 10whenalsoLimitIpis on; set it to aboutlimit × (people/devices per IP)for your app. (A request already denied by the user's own limit does not spend an IP token.)You must declare your proxy topology — there is no safe default. Every client-IP header is attacker-supplied unless a proxy you control writes it, and which header that is depends on your deployment, not on the HTTP spec. Both possible defaults were tried here and both shipped a measured bypass: preferring
x-real-iplet an attacker rotate it behind an XFF-appending proxy (300/300 through alimit: 5); preferring the lastx-forwarded-forhop let an attacker send their own XFF behind an nginx that only overwritesx-real-ip, where it becomes the last hop (300/300). Same bypass, victim swapped. SogetClientIpguesses nothing: with no topology declared it derives no IP, anonymous traffic is unkeyable, and the fail-closed default denies it. A one-time warning names your options. Declare one:{ trustProxy: true } // your proxy APPENDS x-forwarded-for (Vercel, most ingresses) { ipHeader: 'x-real-ip' } // your proxy OVERWRITES one header (nginx proxy_set_header) { ipHeader: 'cf-connecting-ip' } // Cloudflare { trustProxy: false } // no proxy in front; rely on a server-verified identify()Within
trustProxy: true, the hop that matters is the last one: a proxy appends the address it received from, so the leftmost hops are whatever the client sent. Keying on a leftmost hop lets an attacker rotate a forged entry and mint a bucket per request.Count your trusted proxies.
trustProxy: truereads the last XFF hop, which assumes exactly one trusted proxy sits in front of your app (the common Vercel / single-load-balancer case). If you run N proxies in a chain, the real client is the Nth-from-last hop — the last hop is then your inner proxy, not the client, so keying on it lumps every client behind that proxy into one bucket. With more than one proxy, pin the single header your edge sets withipHeader(e.g.cf-connecting-ipbehind Cloudflare,true-client-ip, or your load balancer's own header) instead of using last-hop.The IP is canonicalized before it becomes a key. Equivalent spellings of one address — IPv6 case/
::-expansion/brackets/zone-id, an IPv4-mapped::ffff: 1.2.3.4folded to its plain1.2.3.4, IPv4 with surrounding whitespace or a:port— all fold to a single bucket key, so a client can't rotate representations to mint fresh buckets and multiply its budget. A value that is not an IP at all — raw header text,unknown, a hostname — is rejected rather than keyed, and falls to the unkeyable path. Both happen inside the limiter itself, so they hold whether you go throughwithRateLimit/getClientIpor callrateLimit().check({ ip })directly. Not recognized as IPs: the rare non-dotted IPv4 encodings (decimal2130706433, hex0x7f000001, octal0177.0.0.1).isIPrejects them, so they fall to the unkeyable path — denied by default, and allowed if you setonUnkeyable: 'allow'. A client cannot use them to mint an extra bucket under the default; underallowthey are simply not limited, like any other unkeyable request.Memory is bounded by key COUNT, not by bytes. The store is an in-memory LRU capped at
maxKeys(default 100k) — and there are two of them, a user bucket and an IP bucket, so the real ceiling is2 × maxKeyskeys. A flood of spoofed IPs can't exhaust it. Key size is capped separately: a key longer than 256 characters is not treated as an identity (a UUID is 36, a canonical IPv6 is 39), because the LRU alone would happily hold 100k keys of any length.withRateLimitalso sweeps idle buckets every ~1000 requests. Trade-off of the cap: under a flood of distinct keys large enough to evict an in-use key, that key's bucket is dropped and its counter resets on the next hit — the memory bound is chosen over a perfectly durable counter. RaisemaxKeys, or use the Pro tier's hosted store, if you need the counter to survive key churn at that scale. Per instance — for multi-region, share one store (the Pro tier adds a hosted store + an abuse dashboard).X-RateLimit-Resetis token-bucket semantics, and means different things on the two paths. On an allowed (2xx) response it is seconds until the bucket is full again — on a request that spent 1 of 5 it reads a small non-zero value even with tokens to spare. On a denied (429) response it is seconds until the next single token is available, i.e. it equalsRetry-After, not the time to refill completely. Both are correct for a token bucket, but if you schedule a retry off the 429'sResetexpecting a full refill, you'll retry early. UseRetry-Afterto retry; if you need fixed-window "quota resets at" semantics, derive them yourself. (The one number a client always wants — when may I try again — isRetry-After, on both header families.)
Part of ShipSealed — ship apps that don't leak. MIT licensed.