The abusable-endpoint matrix
Decide which endpoints need a rate limiter, what each one counts, and how to prove coverage stays complete.
In the rate-limiting chapter you wired a limiter onto sign-in, keyed per-IP and per-email.
That endpoint is safe; the rest of the app is not.
The contact form fires Resend on every POST, the search box runs an unindexed query, the presigned-upload route mints a fresh R2 URL on demand, and none of them count a single request.
But limiting every route is also wrong: throttling reads fragments your analytics and trips legitimate users who share a network.
You already own the @upstash/ratelimit API; what’s missing is the discipline above it.
Which endpoints need a limiter, what does each one count, and how do you prove coverage without reading every file?
The answer is a coverage matrix, and every endpoint it leaves uncovered is a ticket the next chapter audits against a real codebase.
Three triggers decide whether an endpoint needs a limiter
Section titled “Three triggers decide whether an endpoint needs a limiter”An endpoint earns a dedicated limiter if it matches any one of three triggers, labeled (a), (b), and (c); the next section’s categories refer back to these letters.
(a) It costs money per call. Every request spends real money at a third party: an LLM completion, a transactional email through Resend, an SMS, any metered API. One attacker hammering this endpoint maps straight onto a line of your next invoice, so the abuse is measured in dollars, not load.
(b) It can be used to attack a third party. The endpoint sends to a victim’s inbox (invitations, password-reset mail, notifications) or makes an outbound fetch on user-controlled input (a link unfurl, a webhook test, an image proxy). You are the relay : your server does the attacker’s sending, under your IP and your domain’s reputation. Leave the invite endpoint open and an attacker turns it into a free inbox-bombing cannon, the bounces and spam complaints landing on your sender domain. The outbound-fetch case carries a second hazard a limiter can’t touch: SSRF . Throttling doesn’t change where the calls go, so the fix is a host allowlist, not a limiter: resolve the URL first and refuse anything off the list or on a private, loopback, or link-local range.
(c) It touches state addressable without authentication. The endpoint is reachable before a session exists: public sign-up, accepting an invite by token, password reset, a public webhook. There’s no user id to count against, so the cheap per-user defenses don’t apply. This is where credential stuffing lives: an attacker replaying millions of leaked credential pairs against an endpoint that doesn’t know who anyone is yet.
The contrapositive matters just as much: an authenticated endpoint that fails all three triggers does not get a hand-rolled limiter. A tenant-scoped list read or a settings toggle costs nothing, can’t be aimed at a victim, and already sits behind a session. These get the wrapper’s coarse per-user default and nothing more, a deliberate answer rather than an omission.
Walk the tree below in order, stopping at the first yes.
A named limiter in lib/rate-limit.ts, called through safeLimit. The next decision is the key: the smallest scope that contains the abuse.
Authenticated, cheap, and can’t be aimed at a victim. The wrapper’s default per-user budget is the whole defense; a hand-rolled limiter here is the over-application mistake.
Seven categories of abusable endpoints
Section titled “Seven categories of abusable endpoints”Run the three triggers across a real codebase and the same handful of endpoint shapes keep recurring. Catalog them once and you recognize a category instead of re-deriving the triggers each time. Auth comes first because you’ve solved it already: the worked example the other six generalize from.
| Category | Example endpoints in this stack | Triggers |
|---|---|---|
| Auth flows covered | sign-in, sign-up, password reset | (b) + (c) |
| Email-sending paths | invitations, notification sends, contact/support forms | (a) + (b) |
| Webhook fan-out | the emails and background jobs a verified webhook triggers | (a) |
| Expensive public reads | search, unindexed-filter lists, AI completions | (a), sometimes (c) |
| File uploads | R2 presigned-URL issuance | (a) |
| Write-heavy actions on shared resources | one attacker filling the org’s quota or flooding a shared collection | (a) |
| Anonymous endpoints | public sign-up, request-demo, public webhook, metrics scrape | (c) |
Most read as obvious; the third is the trap. The webhook receiver is locked down: you verify the signature on the raw body before parsing a byte, so an attacker can’t forge an event. But that says nothing about the work the event sets off downstream. A subscription event passes the signature check and then sends a receipt email and enqueues three background jobs, and Stripe retries failed deliveries, so a flapping endpoint replays the same event many times, each retry re-triggering that fan-out . The receiver is verified; the fan-out is uncapped.
Sort each endpoint below: the easy part is the obvious sends, the work is telling fan-out from receiver and the authenticated list from the public one.
Run the three triggers on each endpoint, then sort it. Drag each item into the bucket it belongs to, then press Check.
POST /contact — sends mail via ResendGET /search?q= — runs an unindexed queryPOST presigned-upload — mints an R2 URLPOST /sign-upGET /invoices — tenant-scoped list readGET /api/health — no cost, no recipient, returns a constantThe key is the smallest scope that contains the abuse
Section titled “The key is the smallest scope that contains the abuse”A limiter counts requests under a key, and choosing that key is the whole decision: the key is the smallest scope that contains the abuse without affecting legitimate use.
Miss in either direction and you have a problem. Too broad, like a per-IP limit on an authenticated action, trips every office and campus, since dozens of real users share one public address through NAT and one busy user exhausts the budget for the rest. Too narrow, like per-resource against an attacker who rotates resources, lets the attack through, since each request lands on a fresh counter.
catches anonymous floods, botnets sharing few addresses
risks tripping whole offices and campuses behind one NAT'd address
catches one tenant spending broadly — mail, storage, compute
risks tripping a large customer's legitimate burst
catches one account abusing a metered or authed action
risks tripping a power user's heavy-but-honest session
catches hammering one specific record
risks tripping nothing broad — but an attacker who rotates resources slips past
The table applies that one rule seven ways.
| Category | Key strategy | Why this scope |
|---|---|---|
| Auth | per-IP and per-email (both must pass) | per-IP alone misses a botnet; per-email alone is the account-lockout vector |
| Email-sending | per-org-per-recipient and per-org-total | stops one org spamming one victim, and one org spamming broadly |
| Webhook fan-out | per-tenant on the fan-out work | the cost is per-customer; the provider’s retries shouldn’t compound it |
| Expensive public reads | per-IP generous when anonymous, per-user tight behind auth | the scope follows whether there’s a session to key on |
| File uploads | per-user-per-day count and per-user-per-minute rate | two windows: cap total volume and cap the burst |
| Write-heavy shared actions | per-org, per resource type | one member’s abuse becomes the org’s cost |
| Anonymous endpoints | per-IP, tight | no user id exists, so the address is all you have |
One shape recurs: auth, email-sending, and uploads each run two keys that both must pass. When one scope catches half the abuse and a second catches the rest, declare two limiters and require both.
Two rules that make limiter coverage auditable
Section titled “Two rules that make limiter coverage auditable”The next problem is staying covered as the app grows: proving, forty endpoints later, that nothing slipped through.
Two rules you already follow turn “is this endpoint covered?” from a judgment call into a one-line grep.
Every limit( call goes through safeLimit(limiter, key).
That wrapper, built in the error-discipline chapter, is the single seam where the fail policy lives: fail open on a Redis or transport error, fail closed only on genuine quota exhaustion.
So the audit is mechanical: grep for any limit( call not fronted by safeLimit, and each hit bypasses your documented fail policy.
Every limiter is declared at module scope in lib/rate-limit.ts.
Module scope lets a limiter’s in-memory cache survive across warm invocations, but what matters for coverage is that one file holds every limiter, so the audit reads top to bottom and sees it all on one screen.
A limiter hidden inside a handler is invisible to that read.
Read the catalog file noticing what each field does for the audit, not just for the API.
import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
export const emailLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '1 h'), prefix: 'rl:email', analytics: true,});
export const uploadLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, '1 d'), prefix: 'rl:upload', analytics: true,});
export const searchLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), prefix: 'rl:search', analytics: true,});One shared client, read once from the env and reused by every limiter below. At module scope it’s created per worker, not per request.
import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
export const emailLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '1 h'), prefix: 'rl:email', analytics: true,});
export const uploadLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, '1 d'), prefix: 'rl:upload', analytics: true,});
export const searchLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), prefix: 'rl:search', analytics: true,});One limiter, one category. Algorithm, budget, and prefix together are the policy: 20 sends per hour under rl:email. A separate export const per category lists coverage line by line.
import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
export const emailLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '1 h'), prefix: 'rl:email', analytics: true,});
export const uploadLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, '1 d'), prefix: 'rl:upload', analytics: true,});
export const searchLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), prefix: 'rl:search', analytics: true,});The prefix namespaces this limiter’s keys in Redis so counts never collide, and it’s the dimension the dashboard groups by. Each limiter needs a distinct one.
import { Ratelimit } from '@upstash/ratelimit';import { Redis } from '@upstash/redis';
const redis = Redis.fromEnv();
export const emailLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(20, '1 h'), prefix: 'rl:email', analytics: true,});
export const uploadLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(30, '1 d'), prefix: 'rl:upload', analytics: true,});
export const searchLimiter = new Ratelimit({ redis, limiter: Ratelimit.slidingWindow(60, '1 m'), prefix: 'rl:search', analytics: true,});Populates the per-prefix timeline in the Upstash dashboard. Without it the limiter works but you can’t observe it.
The 429 response is identical no matter which limiter tripped
Section titled “The 429 response is identical no matter which limiter tripped”Two details from the rate-limiting chapter apply to every covered endpoint.
The body of a 429 Too Many Requests is the same generic line no matter which limiter or key tripped: “Too many attempts. Please try again later.”
This is the user/operator split from the error-discipline chapter: the user gets a sanitized sentence, while the structured operator log records the limiter, key, remaining count, and reset time.
If a per-email limiter answered differently than a per-IP one, an attacker could read the difference to confirm an account exists, so the body is identical every time.
The standard rate-limit headers ship alongside it: RateLimit-Limit, RateLimit-Remaining, and RateLimit-Reset, plus Retry-After on the 429.
They project straight from the limit() result’s limit, remaining, and reset fields.
CAPTCHA is the next gate when per-IP stops being enough
Section titled “CAPTCHA is the next gate when per-IP stops being enough”Per-IP limiting assumes the attacker controls few addresses. On a public endpoint like sign-up or request-demo, a distributed botnet breaks that assumption: it spreads traffic across thousands of residential IPs, so no single address ever trips the limit. When such an endpoint is consistently maxed by traffic that looks like many separate humans, per-IP has run out of road, and the next gate is a CAPTCHA such as Cloudflare Turnstile, free and invisible for most real users. Reach for it only when per-IP is genuinely insufficient; wiring it is out of scope.
Build the coverage matrix
Section titled “Build the coverage matrix”The lesson’s deliverable is one table with five columns:
endpoint / category · file path · limiter prefix · key strategy · covered (Y/N)
You don’t memorize which endpoints are protected; you regenerate this table on every audit pass.
Grep lib/rate-limit.ts for the declared limiters, cross-reference against your endpoint inventory, and fill one row per endpoint.
Every “Y” cites a real limiter; every “N” is a gap, and every gap is a ticket.
Here is the shape, partially filled for this stack:
| Endpoint / category | File | Limiter prefix | Key strategy | Covered |
|---|---|---|---|---|
| Sign-in (auth) | app/(auth)/sign-in/... | rl:signin | per-IP + per-email | Y |
| Contact form | app/(marketing)/contact/... | rl:email | per-org-per-recipient + per-org-total | Y |
| Stripe webhook fan-out | app/api/webhooks/stripe/... | — | — | N |
| Search | app/(app)/search/... | rl:search | per-user (authed) | Y |
| Presigned upload | app/api/uploads/sign/... | — | — | N |
| Public sign-up | app/(auth)/sign-up/... | rl:signup | per-IP + per-email | Y |
Two N’s, two tickets. The matrix protects nothing by itself, but it makes the holes impossible to overlook.
Now finish one yourself: the triggers reach a verdict, the scope principle picks a key.
Complete the matrix: pick the key strategy and the coverage verdict each cell calls for. Pick the right option from each dropdown, then press Check.
Category File Prefix Key strategy Covered---------------- ---------------------------- --------- -------------- -------Email-sending lib/email/* rl:email ___ NWebhook fan-out app/api/webhooks/stripe/* per-tenant ___Re-run the pass whenever the surface changes; this checklist is that pass in tickable form.
limit( call goes through safeLimit.lib/rate-limit.ts with a distinct prefix.N.One last guardrail before you tune any numbers. A limiter set too tight is worse than none: it takes your product down for real users while the attacker shrugs and moves on. Set budgets at roughly the 99th percentile of legitimate use, above what real users do and below what an attacker needs.