DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Five gates in one API wrapper, and the order is the whole design

Every API route in CogniPrep is exported through the same wrapper:

export const POST = withApiHandler(
  async ({ user, request }) => { /* the actual work */ },
  { rateLimit: 'write' }
);
Enter fullscreen mode Exit fullscreen mode

The wrapper does five things. None of them is clever on its own. The design is entirely in which order they run, and every position in that sequence is the answer to a specific question.

1. A global limit, keyed on IP, before anything else

const ip = getClientIdentifier(request);

// 100 req/min per IP regardless of endpoint.
const globalResult = await rateLimit(`global:${ip}`, RATE_LIMIT_PRESETS.global);
if (!globalResult.success) {
  return tooManyRequests(requestId, globalResult, 'Too many requests. Please slow down.');
}
Enter fullscreen mode Exit fullscreen mode

This is first because it is the only thing standing between an unauthenticated flood and the Supabase auth round trip further down. Anything placed above it becomes work an attacker can make you do for free. It has to be keyed on IP, because at this point in the request there is no other identity available.

getClientIdentifier is worth its own look, because the obvious implementation is exploitable:

// x-real-ip is set by Vercel/trusted proxies and cannot be spoofed by clients
const realIp = getHeader('x-real-ip');
if (realIp?.trim()) return realIp.trim();

// x-forwarded-for is a fallback: take only the LAST (rightmost) IP added
// by a trusted proxy, not the first (which can be client-supplied)
const forwardedFor = getHeader('x-forwarded-for');
if (forwardedFor?.trim()) {
  const ips = forwardedFor.split(',').map((ip) => ip.trim()).filter(Boolean);
  if (ips.length > 0) return ips[ips.length - 1];
}
Enter fullscreen mode Exit fullscreen mode

Taking x-forwarded-for.split(',')[0] is the standard snippet, and it lets any caller rotate their own rate-limit identity by sending whatever they like. Our server actions used to do exactly that on login and password reset. Rightmost, not leftmost.

And when there is no trustworthy IP at all, the tempting move is to return a constant like 'unknown'. That pools every unidentifiable caller into one bucket, so one client exhausts the limit for all of them. On a misconfigured deploy where those headers are always missing, that is your entire user base sharing a single 100/min allowance. We hash a few coarse request attributes instead:

const hash = createHash('sha256').update(fingerprintSource).digest('hex').slice(0, 16);
return `anon:${hash}`;
Enter fullscreen mode Exit fullscreen mode

Weaker than a real IP. It is a degraded mode, not a security boundary. But it contains a blast radius instead of amplifying it.

2. CSRF before authentication

if (verifyCsrf && ['POST', 'PUT', 'DELETE', 'PATCH'].includes(request.method)) {
  if (!verifyCsrfToken(request)) {
    return NextResponse.json({ error: 'Invalid request origin' }, { status: 403 });
  }
}
Enter fullscreen mode Exit fullscreen mode

This one surprises people, because CSRF feels like a session concern and sessions come from auth. But the check itself is a pure header comparison:

export function verifyCsrfToken(request: NextRequest): boolean {
  const origin = request.headers.get('origin');
  const host = request.headers.get('host');

  if (origin && host) {
    try {
      return new URL(origin).host === host;
    } catch {
      return false;
    }
  }
  // ...referer fallback, then reject
  return false;
}
Enter fullscreen mode Exit fullscreen mode

No I/O. No database. Nothing about the user. So there is no reason to pay for a Supabase auth call on a request that is going to be rejected anyway. Cheap and deterministic checks belong above expensive ones, and ordering by cost is free performance on exactly the requests you least want to spend money on.

The final return false matters too. No origin and no referer means reject. Fail closed.

3. Authentication, in the route, not only in middleware

// 3. Authentication (do not rely on middleware alone: CVE-2025-29927)
let user = null;
if (needsAuth) {
  const authResult = await requireAuth();
  if (authResult.error) return authResult.error;
  user = authResult.user;
}
Enter fullscreen mode Exit fullscreen mode

That comment is the load-bearing part. CVE-2025-29927 was the Next.js middleware bypass: a crafted x-middleware-subrequest header let a request skip middleware entirely. If middleware is your only auth gate, your whole authenticated surface is open for the duration.

It is patched. The lesson survives the patch. Middleware in this app is a performance and UX gate, and every route behind it also checks for itself. Two layers where one of them can be bypassed by a header is not paranoia, it is just how you write a thing you cannot personally guarantee.

4. The per-route limit, keyed on the user this time

Here is the part I actually think is interesting:

const limitSubject = user?.id ?? ip;
Enter fullscreen mode Exit fullscreen mode

The identity used for rate limiting changes halfway through the wrapper. The global gate is keyed on IP because nothing better exists yet. The per-route gate runs after auth, so it can key on the user id.

This was a bug report before it was a design. Keying everything on IP pooled everyone behind one NAT into a single bucket. A university network or a mobile carrier doing CGNAT means one heavy user exhausts the per-route limit for every other person on that network. Students sitting practice tests in the same computer lab are exactly our user base, so this was not a theoretical edge case.

Falling back to ip keeps unauthenticated routes protected. You get per-user fairness where identity exists and per-IP protection where it does not.

The two windows are then checked concurrently rather than as two sequential round trips to Upstash:

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),
]);
Enter fullscreen mode Exit fullscreen mode

Presets, and the namespace bug hiding in them

The preset table encodes what each class of endpoint costs us:

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 },
Enter fullscreen mode Exit fullscreen mode

Two things in there are load-bearing.

failClosed reverses the usual choice. Most rate limiters fail open when Redis is unreachable, because taking the app down over a cache outage is worse than serving unlimited requests for a few minutes. That reasoning breaks for endpoints that trigger third-party spend. During a Redis outage an open door on the interview or checkout routes translates directly into an OpenAI or Stripe bill with nothing capping it. Those presets return 429 until Redis recovers.

The other one is namespace, added after a genuinely annoying bug. Two limits with the same numbers share a bucket whenever they are called with the same identifier. Email-verification resends and password resets were both 3 per hour keyed on the bare client IP, so three resends locked password reset for that IP for the rest of the hour, and on a shared campus address, for everyone behind it. Now rateLimitByPreset supplies the preset name as the keyspace automatically, so no future pair of presets can alias by accidentally picking the same limit and window.

5. Errors, with an ID the user can quote

const requestId = crypto.randomUUID();
// ...
} catch (error) {
  logError(`Route handler error [${requestId}]: ${errorMessage}`, error);
  return NextResponse.json(
    { error: errorMessage },
    { status: 500, headers: { 'X-Request-ID': requestId } }
  );
}
Enter fullscreen mode Exit fullscreen mode

The id is minted at the top of the wrapper and attached to every response, successful or not, along with the rate-limit headers. A support message that says "it failed" is an investigation. A support message that quotes an X-Request-ID is a log search.

What you give up

One wrapper means one set of defaults, and defaults that are wrong for a route have to be turned off explicitly:

export interface RouteHandlerOptions {
  rateLimit?: RateLimitPreset;   // default: 'default'
  requireAuth?: boolean;          // default: true
  verifyCsrf?: boolean;           // default: true
  errorMessage?: string;
}
Enter fullscreen mode Exit fullscreen mode

All three security defaults are the safe value. A public endpoint has to say requireAuth: false out loud. That is the right way round: forgetting the option gives you a route that is too strict, which shows up in development immediately, rather than a route that is too open, which shows up in someone else's blog post.

See it

Sign in at cogniprep.app, open DevTools, and watch the Network tab while the dashboard loads. Any request to a /api/ path carries the wrapper's fingerprint in its response headers: X-Request-ID, plus X-RateLimit-Limit, X-RateLimit-Remaining and X-RateLimit-Reset. The Limit value tells you which preset that route was registered with. A request to a game or score endpoint will not show the same number as a checkout one.

Free practice is at cogniprep.app/games if you want a route to poke at.

Top comments (0)