Every API route in CogniPrep is wrapped:
export const POST = withApiHandler(
async ({ user, request }) => { /* ... */ },
{ rateLimit: 'write' }
);
The wrapper does five things before the handler is called. None of them is clever on its own. What took iterations to get right was the order, and the choice of key at each step.
1. A global limit, keyed on IP, first
const globalResult = await rateLimit(`global:${ip}`, RATE_LIMIT_PRESETS.global); // 100/min
100 requests per minute per IP, across every endpoint, regardless of what the route is or who is calling.
It is first because it is the only thing standing between an unauthenticated flood and the authentication round trip below. Authentication costs a call to our auth provider. If the first thing an anonymous request triggers is a network call, then the cheapest request to serve is also the most expensive one to reject, which is exactly backwards.
It is keyed on IP because at this point in the pipeline there is nothing else to key on. That is a real downside, discussed below, and it is why this is not the only limit.
2. CSRF, before authentication
if (verifyCsrf && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
if (!verifyCsrfToken(request)) return json({ error: 'Invalid request origin' }, 403);
}
Same reasoning, one layer up. CSRF verification here is a header comparison: pure CPU, no I/O. Authentication is a network round trip. Running the free check first means a request we are going to reject anyway never pays for the expensive one.
Ordering checks by cost, cheapest first, is almost always right when the checks are independent. It stops being right when an earlier check leaks information the later one would have hidden, which is not the case for "your origin header is wrong".
3. Authentication, in the route, not in middleware
// Do not rely on middleware alone (CVE-2025-29927)
if (needsAuth) {
const authResult = await requireAuth();
if (authResult.error) return authResult.error;
}
We do run middleware, and it does session work. But the authorisation decision is made inside the wrapper, in the route's own execution, because middleware-only authorisation has already failed the industry once: CVE-2025-29927 let a crafted internal header cause Next.js middleware to be skipped entirely. Every route protected only by middleware was open.
The lesson is not specific to that CVE. If a protection lives in a layer that something else decides whether to run, then that decision is part of your security model whether you meant it to be or not. The wrapper runs in the same function as the handler and there is no configuration that skips it.
4. The per-route limit, keyed on the user
const limitSubject = user?.id ?? ip;
const [routeResult, hourlyResult] = await Promise.all([
rateLimitByPreset(`${limitSubject}:${routePath}`, preset),
presetConfig.hourlyLimit !== undefined
? rateLimit(`${limitSubject}:${routePath}:hourly`, { limit: presetConfig.hourlyLimit, windowMs: 3_600_000 })
: Promise.resolve(null),
]);
Two things here.
Key on the user once you know who they are. Our audience is mostly students, and a university network or a CGNAT provider puts thousands of people behind one address. Keying the per-route limit on IP pooled all of them into one bucket, so one heavy user could exhaust the limit for everyone else on their network. The IP fallback stays for unauthenticated routes, where there is nothing better.
This is exactly why step 4 sits after step 3 rather than being folded into step 1. Knowing who the caller is changes what the right key is, so the strictest limits are applied at the point where the most is known.
Minute and hour windows are independent, so they run concurrently. Two sequential Redis round trips before a handler runs is latency you pay on every request. Promise.all makes it one.
Presets, and the two words "fail closed"
export const RATE_LIMIT_PRESETS = {
global: { limit: 100, windowMs: 60_000 },
default: { limit: 60, windowMs: 60_000 },
write: { limit: 30, windowMs: 60_000 },
interview: { limit: 20, windowMs: 60_000, hourlyLimit: 10, failClosed: true },
expensive: { limit: 10, windowMs: 60_000, failClosed: true },
sensitive: { limit: 5, windowMs: 60_000, failClosed: true },
auth: { limit: 5, windowMs: 600_000 },
passwordReset: { limit: 3, windowMs: 3_600_000 },
// ...
};
Most presets fail open: if Redis is unreachable, the request is allowed. A rate limiter that takes the whole product down when its datastore blinks is worse than the abuse it prevents.
The ones marked failClosed are the ones where a request costs real money: AI processing and checkout creation. A Redis outage must not remove the only cap on spend. So the rule is not "fail open" or "fail closed", it is "fail open unless the request spends money", and it is recorded per preset rather than argued about per route.
There is a related judgement call in the email presets. Password reset is 3 per hour. Verification-email resends get their own roomier bucket of 5 per hour, because a resend is requested precisely because the first mail did not arrive, and our auth provider already throttles per address. Reusing the password reset bucket there would have punished a user for our delivery problem.
5. The envelope
Every response, success or failure, carries a request id generated at the top of the wrapper, and rate limit headers. A 429 additionally carries Retry-After. Unhandled exceptions become a structured 500 with the same request id, logged with that id, so a user reporting "it said something went wrong" can be matched to one log line.
See it
Open cogniprep.app/help, then in the console:
const r = await fetch('/api/auth/session');
[...r.headers].filter(([k]) => k.startsWith('x-rate') || k === 'x-request-id');
Right now that returns:
x-ratelimit-limit 60
x-ratelimit-remaining 59
x-ratelimit-reset 2026-09-27T15:32:00.000Z
x-request-id f29c80b7-71f2-4192-b2bb-120747f72e75
Run the fetch in a loop and watch remaining fall and reset stay fixed until the window rolls. That endpoint is on the default preset, which is why the limit reads 60: it is a cheap read that says whether your browser currently has a session. The endpoints that cost money are on much smaller numbers, for the reason above.
The takeaway
A route wrapper is mostly a list of concerns everyone already knows they need. The value is in the sequencing: free checks before expensive ones, identity established before limits that should be keyed on identity, and protections living in the same execution as the handler rather than in a layer that can be skipped.
Top comments (0)